Skip to main content

forma_server/
render.rs

1//! Checked document rendering and per-request metadata.
2//! Server rendering is entered only through a finalized bound page handle.
3
4use 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/// What a render takes beyond [`PageConfig`]: the policy's typed extensions,
14/// the walk's budgets, and the typed homes for what applications used to put
15/// in `body_prefix`: the route's `modulepreload` links from the
16/// build's manifest, and head links. Never a nonce: the render draws one per
17/// response. Data goes through [`PageConfig::config_json`].
18///
19/// ```
20/// use forma_server::{HeadLink, PolicyExtensions, RenderCtx, WalkLimits};
21///
22/// let ctx = RenderCtx::new()
23///     .with_policy(PolicyExtensions::new().img_src("https:"))
24///     .with_limits(WalkLimits { max_list_iterations: 100_000, ..WalkLimits::default() })
25///     .with_head_link(HeadLink::new("icon", "/favicon.svg")?);
26/// # let _ = ctx;
27/// # Ok::<(), forma_server::HeadLinkError>(())
28/// ```
29#[derive(Debug, Clone, Default)]
30pub struct RenderCtx {
31    policy: PolicyExtensions,
32    limits: Option<WalkLimits>,
33    module_preloads: Option<ModulePreloads>,
34    head_links: Vec<HeadLink>,
35    title: Option<String>,
36    body_class: Option<String>,
37    config_json: Option<serde_json::Value>,
38    personality_css: Option<String>,
39    body_prefix: Option<crate::RawHtml>,
40}
41
42impl RenderCtx {
43    /// The strict policy with limits inherited from the bound page.
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Override the bound page's document title for this request; escaped as text.
49    #[must_use]
50    pub fn with_title(mut self, title: impl Into<String>) -> Self {
51        self.title = Some(title.into());
52        self
53    }
54
55    /// Set this request's body class; escaped as an attribute.
56    #[must_use]
57    pub fn with_body_class(mut self, class: impl Into<String>) -> Self {
58        self.body_class = Some(class.into());
59        self
60    }
61
62    /// Request data written as escaped JSON, never executable handler script.
63    #[must_use]
64    pub fn with_config_json(mut self, value: serde_json::Value) -> Self {
65        self.config_json = Some(value);
66        self
67    }
68
69    /// Request branding CSS. The document writer neutralizes style end tags.
70    #[must_use]
71    pub fn with_personality_css(mut self, css: impl Into<String>) -> Self {
72        self.personality_css = Some(css.into());
73        self
74    }
75
76    /// Explicit trusted markup before the page root; plain strings do not satisfy this API.
77    #[must_use]
78    pub fn with_body_prefix(mut self, html: crate::RawHtml) -> Self {
79        self.body_prefix = Some(html);
80        self
81    }
82
83    pub(crate) fn config_json(&self) -> Option<&serde_json::Value> {
84        self.config_json.as_ref()
85    }
86    pub(crate) fn personality_css(&self) -> Option<&str> {
87        self.personality_css.as_deref()
88    }
89    pub(crate) fn body_prefix(&self) -> Option<&crate::RawHtml> {
90        self.body_prefix.as_ref()
91    }
92
93    pub(crate) fn title(&self) -> Option<&str> {
94        self.title.as_deref()
95    }
96    pub(crate) fn body_class(&self) -> Option<&str> {
97        self.body_class.as_deref()
98    }
99
100    /// Extend this page's policy (see [`PolicyExtensions`]).
101    #[must_use]
102    pub fn with_policy(mut self, policy: PolicyExtensions) -> Self {
103        self.policy = policy;
104        self
105    }
106
107    /// Override both walk budgets for this request only. Without this call,
108    /// the handle's bind-time limits apply. Use its `limits()` value with struct
109    /// update syntax to change just one budget.
110    #[must_use]
111    pub fn with_limits(mut self, limits: WalkLimits) -> Self {
112        self.limits = Some(limits);
113        self
114    }
115
116    /// Use the bound page's limits unless this request explicitly overrides them.
117    pub(crate) fn limits(&self, inherited: WalkLimits) -> WalkLimits {
118        self.limits.unwrap_or(inherited)
119    }
120
121    /// Write the route's `modulepreload` links from the build's manifest,
122    /// each with the page's nonce, after the route's own modules. The
123    /// typed home for a preload workaround in `body_prefix`, such as
124    /// Ledger's. The table covers every route; the render takes
125    /// `config.route_pattern`'s list. Like every typed home, each call site
126    /// belongs on the application's raw-entry allowlist (`forma-raw-lint`).
127    ///
128    /// ```no_run
129    /// use forma_server::{assets, ModulePreloads, RenderCtx};
130    ///
131    /// // At start, beside the manifest; refuse to start on its error.
132    /// fn start<Assets: rust_embed::Embed>() -> Result<ModulePreloads, String> {
133    ///     assets::try_load_module_preloads::<Assets>()
134    /// }
135    ///
136    /// // Per request.
137    /// fn ctx(preloads: &ModulePreloads) -> RenderCtx {
138    ///     RenderCtx::new().with_module_preloads(preloads)
139    /// }
140    /// ```
141    ///
142    /// Verified by: crates/forma-server/tests/internal_renderer/typed_homes.rs > "try_render_writes_the_routes_manifest_modulepreload_links_nonced"
143    #[must_use]
144    pub fn with_module_preloads(mut self, preloads: &ModulePreloads) -> Self {
145        self.module_preloads = Some(preloads.clone());
146        self
147    }
148
149    /// Add a `<link>` to the page's head (see [`HeadLink`]), after any added
150    /// before it. The typed home for links spliced in before `</head>`; each
151    /// call site belongs on the application's raw-entry allowlist
152    /// (`forma-raw-lint`).
153    ///
154    /// Verified by: crates/forma-server/tests/internal_renderer/typed_homes.rs > "head_links_are_written_escaped_in_the_head_in_the_order_added"
155    #[must_use]
156    pub fn with_head_link(mut self, link: HeadLink) -> Self {
157        self.head_links.push(link);
158        self
159    }
160
161    /// The route's manifest preloads, empty without a table.
162    pub(crate) fn module_preloads(&self, route_pattern: &str) -> &[String] {
163        self.module_preloads
164            .as_ref()
165            .map_or(&[], |table| table.route(route_pattern))
166    }
167
168    /// The head links, in the order added.
169    pub(crate) fn head_links(&self) -> &[HeadLink] {
170        &self.head_links
171    }
172}
173
174/// Why a page was not rendered. Nothing reaches the browser unless the
175/// handler sends it: answer with the application's own error page.
176///
177/// Every error has a stable [`code`](RenderError::code), the
178/// [`route`](RenderError::route) it was rendering, and the
179/// [`component_path`](RenderError::component_path) where it stopped (empty
180/// when the failure is not inside a component). The codes, by family:
181///
182/// | Code | Variant | Cause |
183/// |---|---|---|
184/// | RENDER-101 | [`MissingModule`](RenderError::MissingModule) | The config has no module or no slots to render |
185/// | RENDER-104 | [`Manifest`](RenderError::Manifest) | Invalid manifest or referenced integrity |
186/// | RENDER-103 | [`RouteMode`](RenderError::RouteMode) | A shell route is absent or not Browser |
187/// | RENDER-406 | [`Unfinalized`](RenderError::Unfinalized) | Required startup finalization is incomplete |
188/// | RENDER-102 | [`Walk`](RenderError::Walk) | The walk refused the module or its data |
189/// | RENDER-201 | [`Policy`](RenderError::Policy) | A policy extension is not a source a page may add |
190/// | RENDER-202 | [`Nonce`](RenderError::Nonce) | The system random source failed to draw a nonce |
191/// | RENDER-301 | [`Budget`](RenderError::Budget) | The walk's output passed `max_output_bytes` |
192/// | RENDER-302 | [`Budget`](RenderError::Budget) | The walk's list rows passed `max_list_iterations` |
193/// | RENDER-303 | [`Budget`](RenderError::Budget) | Lists or control flow nested past the walker's depth |
194/// | RENDER-399 | [`Fault`](RenderError::Fault) | The render panicked; a defect, not an input |
195///
196/// A code, once published, keeps its meaning; new causes get new codes.
197#[derive(Debug, Clone, PartialEq, Eq)]
198#[non_exhaustive]
199pub enum RenderError {
200    /// RENDER-104: manifest or referenced resource integrity is invalid.
201    #[non_exhaustive]
202    Manifest {
203        /// The requested route.
204        route: String,
205        /// Manifest validation failure or the resource whose integrity is unavailable.
206        message: String,
207    },
208    /// RENDER-103: a browser shell requires an explicitly declared browser route.
209    #[non_exhaustive]
210    RouteMode {
211        /// The requested route.
212        route: String,
213    },
214    /// RENDER-406: every SSR handler must bind and the registry must finalize first.
215    #[non_exhaustive]
216    Unfinalized {
217        /// The bound route.
218        route: String,
219    },
220    /// RENDER-401..404: a handler value violates its declared contract.
221    #[non_exhaustive]
222    Props {
223        /// Checked value error, including its declared property path and sources.
224        cause: forma_ir::props::PropsError,
225    },
226    /// RENDER-405: a development reload no longer matches the bound handler.
227    #[non_exhaustive]
228    Binding {
229        /// Bound route.
230        route: String,
231        /// Binding refusals for the changed module. No partial HTML is returned.
232        errors: Vec<forma_ir::props::PropsError>,
233    },
234    /// RENDER-101: Server rendering, and the config
235    /// carries no module or no slots for it. A browser-rendered route is
236    /// served by [`render_browser_shell`], by declaration.
237    #[non_exhaustive]
238    MissingModule {
239        /// The route being rendered.
240        route: String,
241    },
242    /// RENDER-102: the walk refused the module or the data it was given.
243    #[non_exhaustive]
244    Walk {
245        /// The route being rendered.
246        route: String,
247        /// The islands open where the walk stopped, outermost first.
248        component_path: Vec<String>,
249        /// How far the walk got.
250        counts: RenderCounts,
251        /// The walker's error.
252        cause: IrError,
253    },
254    /// RENDER-201: a policy extension was refused; there is no page to send
255    /// without its policy.
256    #[non_exhaustive]
257    Policy {
258        /// The route being rendered.
259        route: String,
260        /// The refused extension.
261        cause: PolicyError,
262    },
263    /// RENDER-202: no nonce could be drawn, so no policy could bless the page.
264    #[non_exhaustive]
265    Nonce {
266        /// The route being rendered.
267        route: String,
268        /// The failed draw.
269        cause: NonceError,
270    },
271    /// RENDER-301, RENDER-302, RENDER-303: the walk reached one of its
272    /// budgets, and stopped there instead of running on.
273    #[non_exhaustive]
274    Budget {
275        /// The route being rendered.
276        route: String,
277        /// The islands open where the walk stopped, outermost first.
278        component_path: Vec<String>,
279        /// How far the walk got before the budget stopped it.
280        counts: RenderCounts,
281        /// The budget the walker reports.
282        cause: IrError,
283    },
284    /// RENDER-399: the render panicked and the panic was caught. The
285    /// process and every other request carry on, while the application
286    /// unwinds on panic; under `panic = "abort"` the process ends.
287    #[non_exhaustive]
288    Fault {
289        /// The route being rendered.
290        route: String,
291        /// The islands open where the walk panicked, when it was in the walk.
292        component_path: Vec<String>,
293        /// How far the walk got, when it was in the walk.
294        counts: RenderCounts,
295        /// The panic's message.
296        message: String,
297    },
298}
299
300impl RenderError {
301    /// The stable code, such as `RENDER-301`.
302    pub fn code(&self) -> &'static str {
303        match self {
304            RenderError::Manifest { .. } => "RENDER-104",
305            RenderError::RouteMode { .. } => "RENDER-103",
306            RenderError::Unfinalized { .. } => "RENDER-406",
307            RenderError::Props { cause } => cause.code(),
308            RenderError::Binding { .. } => "RENDER-405",
309            RenderError::MissingModule { .. } => "RENDER-101",
310            RenderError::Walk { .. } => "RENDER-102",
311            RenderError::Policy { .. } => "RENDER-201",
312            RenderError::Nonce { .. } => "RENDER-202",
313            RenderError::Budget { cause, .. } => match cause {
314                IrError::OutputLimitExceeded { .. } => "RENDER-301",
315                IrError::ListBudgetExceeded { .. } => "RENDER-302",
316                _ => "RENDER-303",
317            },
318            RenderError::Fault { .. } => "RENDER-399",
319        }
320    }
321
322    /// The route pattern the render was for.
323    pub fn route(&self) -> &str {
324        match self {
325            RenderError::Manifest { route, .. }
326            | RenderError::RouteMode { route }
327            | RenderError::Unfinalized { route } => route,
328            RenderError::Props { cause } => cause.route(),
329            RenderError::Binding { route, .. } => route,
330            RenderError::MissingModule { route }
331            | RenderError::Walk { route, .. }
332            | RenderError::Policy { route, .. }
333            | RenderError::Nonce { route, .. }
334            | RenderError::Budget { route, .. }
335            | RenderError::Fault { route, .. } => route,
336        }
337    }
338
339    /// The declared property path of a checked-value refusal.
340    pub fn property_path(&self) -> Option<&str> {
341        match self {
342            Self::Props { cause } => Some(cause.path()),
343            _ => None,
344        }
345    }
346
347    /// The component names of the islands open where the render stopped,
348    /// outermost first; empty when it stopped outside every island or before
349    /// the walk. A component the compiler inlined into its parent has no name
350    /// until FMIR v3's source positions.
351    pub fn component_path(&self) -> &[String] {
352        match self {
353            RenderError::Walk { component_path, .. }
354            | RenderError::Budget { component_path, .. }
355            | RenderError::Fault { component_path, .. } => component_path,
356            _ => &[],
357        }
358    }
359
360    /// How far the walk got, for an error raised during it.
361    pub fn counts(&self) -> Option<RenderCounts> {
362        match self {
363            RenderError::Walk { counts, .. }
364            | RenderError::Budget { counts, .. }
365            | RenderError::Fault { counts, .. } => Some(*counts),
366            _ => None,
367        }
368    }
369}
370
371impl fmt::Display for RenderError {
372    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373        write!(f, "{} {}", self.code(), self.route())?;
374        let path = self.component_path();
375        if !path.is_empty() {
376            write!(f, " in {}", path.join(" > "))?;
377        }
378        match self {
379            RenderError::Manifest { message, .. } => write!(f, ": {message}"),
380            RenderError::RouteMode { .. } => {
381                write!(f, ": route is not explicitly browser-rendered")
382            }
383            RenderError::Unfinalized { .. } => write!(
384                f,
385                ": bind every SSR route and finalize Pages before serving"
386            ),
387            RenderError::Props { cause } => write!(f, ": {cause}"),
388            RenderError::Binding { errors, .. } => {
389                for error in errors {
390                    write!(f, "; {error}")?;
391                }
392                Ok(())
393            }
394            RenderError::MissingModule { .. } => {
395                write!(f, ": no module or slots to render on the server")
396            }
397            RenderError::Walk { cause, .. } | RenderError::Budget { cause, .. } => {
398                write!(f, ": {cause}")
399            }
400            RenderError::Policy { cause, .. } => write!(f, ": {cause}"),
401            RenderError::Nonce { cause, .. } => write!(f, ": {cause}"),
402            RenderError::Fault { message, .. } => write!(f, ": render panicked: {message}"),
403        }?;
404        if let Some(c) = self.counts() {
405            write!(
406                f,
407                " (after {} nodes, {} bytes, {} list rows)",
408                c.nodes, c.bytes, c.list_iterations
409            )?;
410        }
411        Ok(())
412    }
413}
414
415impl std::error::Error for RenderError {
416    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
417        match self {
418            RenderError::Walk { cause, .. } | RenderError::Budget { cause, .. } => Some(cause),
419            RenderError::Props { cause } => Some(cause),
420            RenderError::Binding { errors, .. } => {
421                errors.first().map(|error| error as &dyn std::error::Error)
422            }
423            RenderError::Policy { cause, .. } => Some(cause),
424            RenderError::Nonce { cause, .. } => Some(cause),
425            _ => None,
426        }
427    }
428}
429
430/// The sole private SSR entry: its module and slots come from a verified handle.
431pub(crate) fn render_bound(
432    config: &PageConfig,
433    ctx: &RenderCtx,
434    module: Option<&forma_ir::IrModule>,
435    slots: Option<&forma_ir::SlotData>,
436    limits: WalkLimits,
437) -> Result<PageResponse, RenderError> {
438    render_guarded(
439        config,
440        ctx,
441        CspNonce::try_generate,
442        Root::Walk {
443            module,
444            slots,
445            limits,
446        },
447    )
448}
449
450/// Serve a route the build declares browser-rendered (`ssr: false`): the
451/// nonced bundle and the same headers as any page, around an empty root.
452///
453/// By declaration, never in place of a failure: it does not walk. Unknown and
454/// SSR routes are refused before document work. Policy, nonce and fault errors
455/// retain the same response boundary as a bound SSR page.
456///
457/// Verified by: crates/forma-server/tests/internal_renderer/checked_render.rs > "render_browser_shell_serves_the_bundle_around_an_empty_root_with_the_page_headers"
458pub fn render_browser_shell(
459    config: &PageConfig,
460    ctx: &RenderCtx,
461) -> Result<PageResponse, RenderError> {
462    if !config
463        .manifest
464        .route(config.route_pattern)
465        .is_some_and(|route| route.mode == crate::RouteMode::Browser)
466    {
467        return Err(RenderError::RouteMode {
468            route: config.route_pattern.into(),
469        });
470    }
471    config
472        .manifest
473        .validate()
474        .map_err(|message| RenderError::Manifest {
475            route: config.route_pattern.into(),
476            message,
477        })?;
478    render_guarded(config, ctx, CspNonce::try_generate, Root::Shell)
479}
480
481/// What goes in the page's root.
482#[derive(Debug, Clone, Copy)]
483enum Root<'a> {
484    /// The walk's HTML.
485    Walk {
486        module: Option<&'a forma_ir::IrModule>,
487        slots: Option<&'a forma_ir::SlotData>,
488        limits: WalkLimits,
489    },
490    /// Nothing: the browser renders.
491    Shell,
492}
493
494/// The render, with the nonce source a parameter so a test can make it fail,
495/// inside the outer `catch_unwind`.
496///
497/// The walk catches its own panic, with the component path
498/// (`forma_ir::walk_to_html_counted`); this guard covers the rest of the
499/// render, which carries no `unwrap`, `expect` or `panic!` (the crate denies
500/// them) and so has no known panic to catch.
501fn render_guarded(
502    config: &PageConfig,
503    ctx: &RenderCtx,
504    draw: impl FnOnce() -> Result<CspNonce, NonceError>,
505    root: Root<'_>,
506) -> Result<PageResponse, RenderError> {
507    guard(config.route_pattern, || render(config, ctx, draw, root))
508}
509
510/// Catch request-side unwinds, including user-defined props conversion.
511pub(crate) fn guard(
512    route: &str,
513    render: impl FnOnce() -> Result<PageResponse, RenderError>,
514) -> Result<PageResponse, RenderError> {
515    match catch_unwind(AssertUnwindSafe(render)) {
516        Ok(result) => result,
517        Err(payload) => Err(RenderError::Fault {
518            route: route.to_string(),
519            component_path: Vec::new(),
520            counts: RenderCounts::default(),
521            message: panic_message(payload.as_ref()),
522        }),
523    }
524}
525
526fn render(
527    config: &PageConfig,
528    ctx: &RenderCtx,
529    draw: impl FnOnce() -> Result<CspNonce, NonceError>,
530    root: Root<'_>,
531) -> Result<PageResponse, RenderError> {
532    let route = || config.route_pattern.to_string();
533    let nonce = draw().map_err(|cause| RenderError::Nonce {
534        route: route(),
535        cause,
536    })?;
537    // The policy before the walk: an invalid extension costs no walk.
538    let policy = csp::page_policy(&nonce, &ctx.policy).map_err(|cause| RenderError::Policy {
539        route: route(),
540        cause,
541    })?;
542
543    let (ssr_body, counts) = match root {
544        Root::Shell => (None, RenderCounts::default()),
545        Root::Walk {
546            module,
547            slots,
548            limits,
549        } => {
550            let (Some(module), Some(slots)) = (module, slots) else {
551                return Err(RenderError::MissingModule { route: route() });
552            };
553            let started = Instant::now();
554            let walked = forma_ir::walk_to_html_counted(module, slots, limits);
555            let walk_time = started.elapsed();
556            match walked {
557                Ok(out) => (
558                    Some(out.html),
559                    RenderCounts::from_walk(out.counts, walk_time),
560                ),
561                Err(failure) => {
562                    let route = route();
563                    let component_path = failure.component_path;
564                    let counts = RenderCounts::from_walk(failure.counts, walk_time);
565                    let cause = failure.error;
566                    return Err(match cause {
567                        IrError::OutputLimitExceeded { .. }
568                        | IrError::ListBudgetExceeded { .. }
569                        | IrError::ListDepthExceeded { .. }
570                        | IrError::RecursionLimitExceeded => RenderError::Budget {
571                            route,
572                            component_path,
573                            counts,
574                            cause,
575                        },
576                        IrError::Panicked { message } => RenderError::Fault {
577                            route,
578                            component_path,
579                            counts,
580                            message,
581                        },
582                        cause => RenderError::Walk {
583                            route,
584                            component_path,
585                            counts,
586                            cause,
587                        },
588                    });
589                }
590            }
591        }
592    };
593
594    let html = crate::template::write_document(config, &nonce, ssr_body.as_deref(), ctx)?;
595    if let Some(reason) = policy.relaxed() {
596        csp::log_relaxation(config.route_pattern, reason);
597    }
598    Ok(PageResponse::with_policy(html, nonce, policy, counts))
599}
600
601/// A panic payload's message: `panic!` with a literal gives a `&str`, with
602/// formatting a `String`.
603fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
604    if let Some(message) = payload.downcast_ref::<&str>() {
605        (*message).to_string()
606    } else if let Some(message) = payload.downcast_ref::<String>() {
607        message.clone()
608    } else {
609        "a panic with no message".to_string()
610    }
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616    use crate::types::AssetManifest;
617    use std::collections::HashMap;
618
619    fn manifest() -> AssetManifest {
620        AssetManifest {
621            version: 2,
622            writer: "3.0.0".into(),
623            app: "ledger".into(),
624            aliases: HashMap::new(),
625            build: "test".to_string(),
626            assets: HashMap::new(),
627            routes: HashMap::new(),
628        }
629    }
630
631    #[allow(deprecated)]
632    fn shell_config(manifest: &AssetManifest) -> PageConfig<'_> {
633        PageConfig {
634            title: "Seam",
635            route_pattern: "/seam",
636            manifest,
637            config_json: None,
638            body_class: None,
639            personality_css: None,
640            body_prefix: None,
641        }
642    }
643
644    #[test]
645    fn render_202_a_failed_nonce_draw() {
646        // The OS random source cannot be made to fail from a test, so the
647        // render takes its draw as a parameter and this test hands it a
648        // failing one. `try_render` passes `CspNonce::try_generate`.
649        let manifest = manifest();
650        for root in [
651            Root::Walk {
652                module: None,
653                slots: None,
654                limits: WalkLimits::default(),
655            },
656            Root::Shell,
657        ] {
658            let error = render_guarded(
659                &shell_config(&manifest),
660                &RenderCtx::new(),
661                || Err(NonceError),
662                root,
663            )
664            .unwrap_err();
665            assert_eq!(error.code(), "RENDER-202");
666            assert_eq!(error.route(), "/seam");
667            assert!(error.component_path().is_empty());
668            assert_eq!(error.counts(), None);
669        }
670        // The same seam with a real draw renders: the failure is the draw's.
671        let page = render_guarded(
672            &shell_config(&manifest),
673            &RenderCtx::new(),
674            CspNonce::try_generate,
675            Root::Shell,
676        );
677        assert!(page.is_ok());
678    }
679
680    #[test]
681    fn a_panic_outside_the_walk_is_still_a_fault() {
682        // The outer guard: a panic before the walk (here, in the draw) comes
683        // back as RENDER-399 too, with no component path.
684        let manifest = manifest();
685        let error = render_guarded(
686            &shell_config(&manifest),
687            &RenderCtx::new(),
688            || -> Result<CspNonce, NonceError> { panic!("draw blew up") },
689            Root::Shell,
690        )
691        .unwrap_err();
692        assert_eq!(error.code(), "RENDER-399");
693        assert_eq!(error.route(), "/seam");
694        assert!(error.component_path().is_empty());
695        match error {
696            RenderError::Fault { message, .. } => assert_eq!(message, "draw blew up"),
697            other => panic!("expected Fault, got {other:?}"),
698        }
699    }
700
701    #[test]
702    fn an_invalid_extension_fails_before_the_walk() {
703        // No module at all: if the walk ran first this would be RENDER-101.
704        let manifest = manifest();
705        let ctx = RenderCtx::new().with_policy(PolicyExtensions::new().form_action("'none'"));
706        let error = render_bound(
707            &shell_config(&manifest),
708            &ctx,
709            None,
710            None,
711            WalkLimits::default(),
712        )
713        .unwrap_err();
714        assert_eq!(error.code(), "RENDER-201");
715    }
716
717    #[test]
718    fn display_names_code_route_component_and_counts() {
719        let error = RenderError::Budget {
720            route: "/t/:tenant".to_string(),
721            component_path: vec!["Shell".to_string(), "Branding".to_string()],
722            counts: RenderCounts {
723                nodes: 102,
724                bytes: 2048,
725                list_iterations: 50,
726                ..RenderCounts::default()
727            },
728            cause: IrError::ListBudgetExceeded { max: 50 },
729        };
730        let text = error.to_string();
731        assert!(
732            text.starts_with("RENDER-302 /t/:tenant in Shell > Branding: "),
733            "{text}"
734        );
735        assert!(
736            text.ends_with("(after 102 nodes, 2048 bytes, 50 list rows)"),
737            "{text}"
738        );
739    }
740}