Skip to content

fix(router): surface prerender render errors instead of baking them (#587) - #590

Merged
ivogt merged 1 commit into
mainfrom
fix/prerender-silent-bake-error-boundary
Jun 17, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/prerender-silent-bake-error-boundary

Conversation

@ivogt

@ivogt ivogt commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #587.

Problem

When a Prerender route's render throws at build time, the router's error boundary catches it inside resolveAllSegments, turns it into a normal type: "error" segment, and matchForPrerender serializes that error page as a successful prerender artifact. The build logs OK, exits 0, and at runtime the frozen error page is served as a healthy HTTP 200. The failure is silent: CI/status/smoke checks see 200 + valid HTML, so a fully broken page looks healthy.

This bit a real deploy: with preset: "vercel" and a Prerender(...) route whose render reads ctx.env (no buildEnv configured → ctx.env throws at build), every store category page shipped a "Something Went Wrong" error boundary as a 200. The framework already detected the error (the router's onError fired during the build) — it just didn't let that affect the prerender result or the exit status.

Root cause

matchForPrerender → resolveAllSegments wraps each segment in resolveWithErrorBoundary (helpers.ts). A thrown render error is caught there (only Response re-throws), converted by catchSegmentError into a type: "error" segment via the nearest boundary / DefaultErrorFallback, and returned as a successful array. matchForPrerender's only post-resolution checks are detectPrerenderPassthrough + a loader filter — it never inspects for error segments — so it bakes the error page. The existing catch → FAIL → throw in expandPrerenderRoutes never fires because nothing throws.

Fix

The pre-render path resolves segments with a build-only throwOnError flag, threaded through the single ResolveSegmentOptions type (matchForPrerender → resolveAllSegments → resolveWithErrorBoundary). With it set, a thrown render error is re-thrown to the build loop instead of converted into an error segment. The live request path leaves the flag unset, so runtime error boundaries are unchanged.

This also fixes a latent bug: throw new Skip() inside a render fn (documented in errors.ts) was swallowed by the same boundary — it now propagates correctly (the getParams() Skip path always worked).

A new prerender.onError plugin option chooses what the build does with the surfaced error, applied in both the prerender and static loops via a shared resolvePrerenderError helper.

Before / after (consumer)

// urls.tsx — a Prerender route whose render needs request-time data
export const CategoryPage = Prerender(
  async () => [{ slug: "new-arrivals" }, /* … */],
  async (ctx) => {
    const commerce = await makeClient(ctx.env); // throws at build when buildEnv is off
    return <Category data={await commerce.list(ctx.params.slug)} />;
  },
);
  • Before: pnpm build logs OK /shop/new-arrivals, exits 0; runtime serves the baked "Something Went Wrong" page as a 200.
  • After (default): pnpm build logs FAIL /shop/new-arrivals — ctx.env is not available during pre-rendering … and exits non-zero. You fix it: provide buildEnv, wrap in Passthrough(), or throw new Skip().

Opting out / per-route control

// downgrade build-failure to a warn-and-skip
rango({ prerender: { onError: "warn" } });

// or skip a single URL that knowingly can't render at build:
export const CategoryPage = Prerender(getParams, async (ctx) => {
  if (!canRenderAtBuild()) throw new Skip("needs request-time env");
  return <Category … />;
});

Semantics

Build handler outcome onError: "fail" (default) onError: "warn"
renders fine baked, served from store baked, served from store
throw new Skip() (render or getParams) URL skipped (SKIP) URL skipped (SKIP)
render throws build fails, names URL + original error WARN + skip baking; build continues
Passthrough() build handler throws build fails WARN + skip → live handler serves at runtime
ctx.passthrough() defer to live handler (unchanged) defer to live handler (unchanged)
dev /__rsc_prerender render throws surfaces (404 → live fallback) same

onError: "warn" is a build-unblock, not a runtime contract for the skipped entry: the route falls through to normal resolution and may render live (handler still bundled) or 404 (once handler eviction runs for other baked entries). Use Passthrough() for a defined live fallback, throw new Skip() for an intentional skip, or the default "fail".

Code changes

  • src/router/segment-resolution/fresh.ts — ResolveSegmentOptions.throwOnError; pass it to resolveWithErrorBoundary.
  • src/router/segment-resolution/helpers.ts — resolveWithErrorBoundary re-throws when throwOnError (after the Response re-throw).
  • src/router/prerender-match.ts — pass { skipLoaders: true, throwOnError: true }.
  • src/vite/discovery/prerender-collection.ts + src/vite/utils/prerender-utils.ts — shared resolvePrerenderError policy helper (Skip/warn/fail) used by both expandPrerenderRoutes and renderStaticHandlers.
  • src/vite/plugin-types.ts / discovery/state.ts / rango.ts — the prerender.onError option threaded to discovery.
  • src/vite/router-discovery.ts — dev /__rsc_prerender endpoint stays quiet on Skip.
  • src/router/segment-wrappers.ts / router-context.ts / prerender-match.ts — all four resolveAllSegments option surfaces routed through the shared ResolveSegmentOptions (no type drift).

Test plan

  • Unit (src/router/__tests__/prerender-render-error.test.tsx): throwing render is surfaced (rejects with the original error; serializer never reached); throw new Skip() propagates as a Skip; healthy render still serializes — through matchForPrerender.
  • E2e build-level (e2e/prerender-onerror.test.ts + dedicated e2e/prerender-onerror/ app): default build fails (FAIL /prerender-boom + original error); onError: "warn" build succeeds + skips; same for Static() handlers. The fixture has its own projectRoot, so its dist/ never races other e2e builds.
  • pnpm run typecheck (13 packages)
  • pnpm run test:unit:all (router 3964+57, cloudflare-basic 42+10, vite-rsc-demo 15+1, mini 16+1)
  • pnpm run lint (0/0) · pnpm run format · pnpm check:e2e-bucketing
  • Docs: prerender-api-design.md (Render Errors section), feature-map.md, feature-file-map.md, prerender skill.

Notes / call-outs

  • Dev /__rsc_prerender is a deliberate build-only test gap. The endpoint edit is a cosmetic log refinement on a path that serves only non-Node runtimes; the behavioral change (matchForPrerender re-throwing) is unit-covered. A faithful dev test needs a non-Node fixture with buildEnv off — cloudflare-basic uses buildEnv: "auto", where the render-error repro can't occur. Documented in the test header.
  • No "dynamic"/"bake" onError values. "bake" is the bug; a clean "dynamic" can't be offered for a plain Prerender() route (its handler is evicted), so Passthrough() is the explicit live-fallback path.

…587)

A Prerender route's build-time render error was caught by the router error
boundary inside resolveAllSegments, turned into a `type: "error"` segment, and
baked as the prerender artifact — then served at runtime as a healthy HTTP 200.
The build logged OK and exited 0, so CI/status checks saw a valid 200 while the
page was broken (every store category page shipped a "Something Went Wrong" page).

The pre-render path now resolves segments with a build-only `throwOnError` flag
(matchForPrerender -> resolveAllSegments -> resolveWithErrorBoundary, threaded via
ResolveSegmentOptions), so a render failure re-throws to the build loop instead of
being converted into an error segment. The live request path leaves the flag unset,
so runtime error boundaries are unchanged. `throw new Skip()` inside a render fn now
propagates correctly too (it was swallowed by the same boundary).

New `rango({ prerender: { onError: "fail" | "warn" } })` option (default "fail"):
fail-the-build vs. warn-and-skip, applied in both the prerender and static build
loops via the shared resolvePrerenderError helper.

Tests:
- Unit: prerender-render-error.test.tsx (error surfaced with original message /
  Skip propagates / healthy render still bakes), driven through matchForPrerender.
- E2e: dedicated e2e/prerender-onerror fixture app (own projectRoot, so its dist/
  and staged-asset dir never race other e2e builds) + build-level tests for the
  default-fail and warn paths, for both Prerender routes and Static handlers.
- Docs: prerender-api-design.md, feature-map.md, feature-file-map.md, prerender skill.

Notes:
- prerender.onError "warn" is a build-unblock, not a runtime contract for the
  skipped entry (it may live-render or 404 depending on the build); use
  Passthrough()/Skip()/fail for defined behavior.
- The dev /__rsc_prerender endpoint change is a cosmetic log refinement on a
  non-Node-only path; its behavioral change is unit-covered. The endpoint e2e is a
  deliberate build-only gap (no non-Node buildEnv:off fixture exists).
@pkg-pr-new

pkg-pr-new Bot commented Jun 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@590

commit: a61e226

@ivogt
ivogt merged commit 66d7d3c into main Jun 17, 2026
25 checks passed
@ivogt
ivogt deleted the fix/prerender-silent-bake-error-boundary branch June 17, 2026 04:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant