Repository navigation
fix(router): surface prerender render errors instead of baking them (#587) - #590
Merged
Merged
Conversation
…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).
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #587.
Problem
When a
Prerenderroute's render throws at build time, the router's error boundary catches it insideresolveAllSegments, turns it into a normaltype: "error"segment, andmatchForPrerenderserializes that error page as a successful prerender artifact. The build logsOK, exits 0, and at runtime the frozen error page is served as a healthy HTTP 200. The failure is silent: CI/status/smoke checks see200+ valid HTML, so a fully broken page looks healthy.This bit a real deploy: with
preset: "vercel"and aPrerender(...)route whose render readsctx.env(nobuildEnvconfigured →ctx.envthrows at build), every store category page shipped a "Something Went Wrong" error boundary as a 200. The framework already detected the error (the router'sonErrorfired during the build) — it just didn't let that affect the prerender result or the exit status.Root cause
matchForPrerender→resolveAllSegmentswraps each segment inresolveWithErrorBoundary(helpers.ts). A thrown render error is caught there (onlyResponsere-throws), converted bycatchSegmentErrorinto atype: "error"segment via the nearest boundary /DefaultErrorFallback, and returned as a successful array.matchForPrerender's only post-resolution checks aredetectPrerenderPassthrough+ a loader filter — it never inspects for error segments — so it bakes the error page. The existingcatch → FAIL → throwinexpandPrerenderRoutesnever fires because nothing throws.Fix
The pre-render path resolves segments with a build-only
throwOnErrorflag, threaded through the singleResolveSegmentOptionstype (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 inerrors.ts) was swallowed by the same boundary — it now propagates correctly (thegetParams()Skip path always worked).A new
prerender.onErrorplugin option chooses what the build does with the surfaced error, applied in both the prerender and static loops via a sharedresolvePrerenderErrorhelper.Before / after (consumer)
pnpm buildlogsOK /shop/new-arrivals, exits 0; runtime serves the baked "Something Went Wrong" page as a 200.pnpm buildlogsFAIL /shop/new-arrivals — ctx.env is not available during pre-rendering …and exits non-zero. You fix it: providebuildEnv, wrap inPassthrough(), orthrow new Skip().Opting out / per-route control
Semantics
onError: "fail"(default)onError: "warn"throw new Skip()(render or getParams)SKIP)SKIP)WARN+ skip baking; build continuesPassthrough()build handler throwsWARN+ skip → live handler serves at runtimectx.passthrough()/__rsc_prerenderrender throwsonError: "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). UsePassthrough()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 toresolveWithErrorBoundary.src/router/segment-resolution/helpers.ts—resolveWithErrorBoundaryre-throws whenthrowOnError(after theResponsere-throw).src/router/prerender-match.ts— pass{ skipLoaders: true, throwOnError: true }.src/vite/discovery/prerender-collection.ts+src/vite/utils/prerender-utils.ts— sharedresolvePrerenderErrorpolicy helper (Skip/warn/fail) used by bothexpandPrerenderRoutesandrenderStaticHandlers.src/vite/plugin-types.ts/discovery/state.ts/rango.ts— theprerender.onErroroption threaded to discovery.src/vite/router-discovery.ts— dev/__rsc_prerenderendpoint stays quiet onSkip.src/router/segment-wrappers.ts/router-context.ts/prerender-match.ts— all fourresolveAllSegmentsoption surfaces routed through the sharedResolveSegmentOptions(no type drift).Test plan
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 aSkip; healthy render still serializes — throughmatchForPrerender.e2e/prerender-onerror.test.ts+ dedicatede2e/prerender-onerror/app): default build fails (FAIL /prerender-boom+ original error);onError: "warn"build succeeds + skips; same forStatic()handlers. The fixture has its ownprojectRoot, so itsdist/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-bucketingprerender-api-design.md(Render Errors section),feature-map.md,feature-file-map.md, prerender skill.Notes / call-outs
/__rsc_prerenderis 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 (matchForPrerenderre-throwing) is unit-covered. A faithful dev test needs a non-Node fixture withbuildEnvoff — cloudflare-basic usesbuildEnv: "auto", where the render-error repro can't occur. Documented in the test header."dynamic"/"bake"onError values."bake"is the bug; a clean"dynamic"can't be offered for a plainPrerender()route (its handler is evicted), soPassthrough()is the explicit live-fallback path.