Skip to content

Prerender silently bakes & serves the error-boundary page when a render throws (200), instead of failing the build or falling back to dynamic #587

Closed

Description

@ivogt

Summary

When a prerendered route's render throws, the route's error boundary catches it and rango bakes the rendered error page as the prerender output, reporting OK and letting the build succeed. At runtime the baked error page is served as a normal HTTP 200. The failure is completely silent: status checks/CI/smoke tests see 200 + valid HTML, so a fully-broken page looks healthy. You only notice by eyeballing the page.

This bit me on a real deploy: every store category page (/shop/new-arrivals, …) served a "Something Went Wrong" error boundary as a 200.

Repro

  • preset: "vercel", a Prerender(...) route whose render fn reads ctx.env (here: building an SFCC client).
  • buildEnv left at its default (off). "auto" is Cloudflare-only, so on the vercel preset there's no build-time env.
  • At build, the render fn throws ctx.env is not available during pre-rendering.
  • The router's defaultErrorBoundary catches it and renders the error UI.
  • That error UI is baked into the prerender manifest and served at runtime (200, x-vercel-cache: MISS).

Minimal shape:

export const CategoryPage = Prerender(
  async () => [/* paths */],
  async (ctx) => {
    const commerce = await makeClient(ctx.env); // throws during prerender when buildEnv is off
    // ...render
  },
);

Root cause

packages/rangojs-router/src/vite/discovery/prerender-collection.ts, the render loop in expandPrerenderRoutes:

  • routerInstance.matchForPrerender(...) is awaited (around L249).
  • On a returned result, the segments/handles are staged into the prerender manifest and logged OK (around L268–298) — unconditionally, with no inspection of whether the render resolved via an error boundary.
  • The catch that logs FAIL and re-throws to fail the build (around L299–328) only fires if matchForPrerender throws.

But a render error is swallowed by the route's error boundary inside matchForPrerender, which then returns a normal result (the error-boundary segments). So the loop never sees a throw, logs OK, and bakes the error page.

Tellingly, the router's onError does fire during this prerender (the build logs Unhandled error in <app> router: { error: Error: ctx.env is not available during pre-rendering ... }). So the framework already detects the error — it just doesn't let that affect the prerender result or the build's exit status.

Why it's bad

  • Silent, user-visible breakage. Broken pages ship as 200 with valid HTML; CI/status-only checks pass. The build also exits 0.
  • Misleading, because the "fail the build on prerender error" path already exists — it just doesn't trigger, since the error never reaches it.
  • It's specifically nasty with prerender because the bad output is frozen at build and served verbatim; the runtime function (where ctx.env would have existed) never even runs for those URLs.

Proposed fix (options)

  1. Surface error-boundary use from matchForPrerender. Have it report when a render resolved via an error boundary / onError fired (e.g. result.errored or an error field), and in the prerender loop treat that like the existing FAIL path: don't bake it, and either fail the build or fall back to dynamic.
  2. Don't apply the error boundary during prerender (or render in a "throw-on-error" mode), so a render error propagates to the loop's existing catch → FAIL → build fails. This reuses the machinery that's already there.
  3. At minimum, make it loud and configurable: a non-zero build exit (or a prerender: { onError: "fail" | "dynamic" | "bake" } knob) instead of silently baking, plus a warning that lists the URLs that prerendered via an error boundary.

Falling back to dynamic (option 1/2) is especially appropriate here: at runtime ctx.env exists, so these pages would have rendered fine if they hadn't been frozen as build-time errors.

Environment

  • @rangojs/[email protected] (dist-tag preview)
  • preset: "vercel", host router, Node 24 / Vite 8

Found while dogfooding the vercel preset in the rsc-cloudflare-app testbed (full write-up: RANGO-ISSUE-vercel-host-router.md, finding #3). Workaround that fixed it consumer-side: pass a buildEnv so prerender render fns can read ctx.env — but the silent-bake behavior is the real bug.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions