Skip to content

fix(prerender): surface thrown generateStaticParams/getStaticPaths errors - #2017

Merged
james-elicx merged 1 commit into
cloudflare:mainfrom
Xplod13:fix/prerender-surface-static-params-errors
Jun 14, 2026
Merged

fix(prerender): surface thrown generateStaticParams/getStaticPaths errors#2017
james-elicx merged 1 commit into
cloudflare:mainfrom
Xplod13:fix/prerender-surface-static-params-errors

Conversation

@Xplod13

@Xplod13 Xplod13 commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Surface the real error thrown by a user's generateStaticParams / getStaticPaths during prerender instead of swallowing it. The build-time proxies that fetch the prerender static-params/static-paths endpoints now throw the genuine error message on an HTTP 500, so the route is recorded as a per-route error with the real cause (App Router via its existing collector; Pages Router via a new try/catch).
  • Fail the build on these errors in every mode, not just output: 'export'. The per-route error is flagged fatal, and run-prerender now throws for any fatal route regardless of mode — matching next build, which fails on a throwing generateStaticParams/getStaticPaths. Intentionally-skipped dynamic/SSR routes are unaffected (they are not fatal).
  • Keep the genuine disabled / stale-secret case (HTTP 404 / 403) on the existing warn-and-skip path — only a 500 (which carries the user error in its JSON body) is treated as a real failure.
  • Add a parsePrerenderEndpointError helper that extracts the { error } message from the 500 body, falling back to the raw text.

Root Cause

Both build-time proxies (packages/vinext/src/build/prerender.ts) that fetch /__vinext/prerender/static-params (App Router) and /__vinext/prerender/pages-static-paths (Pages Router) treated any !res.ok response identically: they logged "… This may indicate a stale or missing prerender secret." and returned the no-params sentinel (null / { paths: [], fallback: false }), discarding the response body.

But the endpoint (packages/vinext/src/server/app-prerender-endpoints.ts) returns two very different non-ok responses:

  • notFoundResponse()404 for the genuine disabled / stale-secret case;
  • jsonResponse({ error: String(error) }, 500)500 when the user's generateStaticParams / getStaticPaths throws, with the real error in the body.

Because the proxy swallowed the 500, a throwing generateStaticParams was mis-reported as "no static params", the route was silently dropped from prerendering, and the only console output blamed a non-existent secret problem — while the default-mode build still "succeeded" and shipped an app missing pre-rendered pages. In output: 'export' it failed with the misleading Dynamic route requires generateStaticParams() instead of the user's error.

The fix branches on status: a 500 throws parsePrerenderEndpointError(text). For App Router that throw is caught by the existing per-route collector (Failed to call generateStaticParams(): …); for Pages Router the getStaticPaths consumption is now wrapped to produce the same per-route error. Non-500 responses keep the warn-and-skip behavior, so the legitimate secret/disabled path is unchanged.

This matches Next.js, which invokes the user function directly with no swallowing wrapper and fails the build with the real error/stack.

References

Verification

  • pnpm test tests/prerender.test.ts tests/run-prerender-concurrency.test.ts — green (3 new prerender cases: App Router throw flagged fatal, Pages Router throw flagged fatal, 404/secret still warn-skips)
  • The endpoint's own throw→500 path is covered by tests/app-prerender-endpoints.test.ts
  • pnpm test — full Vitest suite green (only pre-existing local-env flakes unrelated to this change)
  • npx vp check on the touched files — format, lint, types clean

@pkg-pr-new

pkg-pr-new Bot commented Jun 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2017
npm i https://pkg.pr.new/vinext@2017

commit: ee4bcff

@Xplod13
Xplod13 force-pushed the fix/prerender-surface-static-params-errors branch from 41fed8e to bed6200 Compare June 14, 2026 19:36
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: surfacing thrown generateStaticParams/getStaticPaths errors

The fix is correct, well-scoped, and matches the intended Next.js parity. I verified the full chain:

  • Endpoint behavior confirmed (server/app-prerender-endpoints.ts): notFoundResponse() → 404 for disabled/stale-secret, jsonResponse({ error: String(error) }, 500) for a thrown user function. Branching on res.status === 500 correctly distinguishes the two and preserves the legitimate warn-and-skip path for non-500 (404/403).
  • App Router throw propagation: the throw from the Proxy get trap (prerender.ts:1062) propagates up through the generateStaticParamsFn calls inside the try block and is caught by the existing per-route collector at prerender.ts:1269 (Failed to call generateStaticParams(): …). Correct.
  • Pages Router: previously there was no try/catch around getStaticPaths (only call site at prerender.ts:752), so a throw would crash the whole prerenderPages loop. The new try/catch converts it to a per-route error, consistent with the surrounding entryError pattern. Correct.
  • parsePrerenderEndpointError fallback to raw text is reasonable for non-JSON 5xx (e.g. a crashing prod server).
  • Tests pass (3 new, 83 total), vp check is clean on both files.

One accuracy issue with the PR description (not the code) — see inline comment.

Comment thread packages/vinext/src/build/prerender.ts Outdated
// statuses (e.g. 404 = disabled/stale secret) keep the warn-and-skip
// behavior. Refs cloudflare/vinext#1982
if (res.status === 500) {
throw new Error(parsePrerenderEndpointError(text));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code here is correct, but the PR description's claim that in default mode "the route fails with the real cause and the build fails" is not accurate.

In default (non-export) mode, a per-route status: "error" only increments the errors counter and is printed in the progress output — run-prerender.ts:343 only throws when mode === "export" && errors > 0. So the default build still completes successfully; this fix makes the error visible and accurately attributed (instead of being mis-reported as a "stale secret" skip), but it does not make the default-mode build fail.

Worth double-checking against Next.js: a throwing generateStaticParams does fail next build in default mode. If matching that is in scope for #1982, default mode would also need to throw on these error routes (or at least on generateStaticParams/getStaticPaths throws specifically, as opposed to e.g. ssr skips). If it's out of scope, consider tightening the PR description to say the error is now surfaced/reported rather than that "the build fails."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right that a per-route error alone didn't fail the default build (run-prerender.ts only threw under mode === "export"). Since #1982 explicitly wants parity with next build (which fails on a throwing generateStaticParams/getStaticPaths), I made it fail rather than just documenting it:

  • The generateStaticParams / getStaticPaths error results are now flagged fatal.
  • run-prerender throws for any fatal route in every mode, before the export-only check.
  • Intentionally-skipped dynamic/SSR routes stay non-fatal, so default-mode leniency is preserved for them — only a genuine thrown user function fails the build.

PR description updated accordingly. Pushed in df06be99.

Comment thread tests/prerender.test.ts
fs.writeFileSync(
path.join(pageDir, "page.tsx"),
"export default function Page() { return null; }\n",
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: because no prerender secret is set, the mock server short-circuits the request before the real app-prerender-endpoints.ts is involved (the stderr shows the prerender secret not found warning). That's fine for unit-testing the proxy's status branching, but these tests don't exercise the real endpoint's 500 path end-to-end. Worth a brief comment noting the endpoint response is mocked, so a future change to the real endpoint's status codes wouldn't be caught here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a note on the describe block clarifying that these tests mock the endpoint's HTTP response (the prod server here has no secret configured), so they exercise the build-side proxy's status branching rather than the real app-prerender-endpoints.ts 500 path end-to-end — and pointing at tests/app-prerender-endpoints.test.ts, which covers the endpoint's own throw→500 ({ error }) behaviour. Pushed in df06be99.

@ask-bonk

ask-bonk Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2017 (review)

Summary

I reviewed PR #2017 and verified the full chain of changes against the source. The fix is correct and well-scoped:

Verified correct:

  • The endpoint (app-prerender-endpoints.ts) does return 404 for disabled/stale-secret and 500 (with { error } body) for user-function throws — so branching on res.status === 500 is the right discriminator.
  • App Router: the throw propagates through the Proxy get trap up to the existing per-route catch at prerender.ts:1269. Correct.
  • Pages Router: previously had no try/catch around getStaticPaths, so a throw would crash the whole loop. The new wrapper converts it to a per-route error, matching the surrounding entryError pattern. Correct.
  • parsePrerenderEndpointError raw-text fallback is reasonable.
  • New tests pass (3 new / 83 total), vp check clean.

One issue raised (inline):

  • The PR description claims that in default mode "the build fails." That's inaccurate — run-prerender.ts:343 only throws in export mode. In default mode, per-route errors are counted/reported but the build still completes. The fix correctly makes the error visible and attributed (vs. the misleading "stale secret" skip), but doesn't make default builds fail. I flagged whether matching Next.js (which fails next build on a thrown generateStaticParams) is in scope, or whether the description should just be tightened.

Minor (inline):

  • The tests mock the endpoint responses directly (no real secret set), so they don't exercise the real endpoint's 500 path end-to-end — worth a clarifying comment.

This was posted as a non-blocking COMMENT review since the code itself is sound.

github run

@Xplod13
Xplod13 force-pushed the fix/prerender-surface-static-params-errors branch from bed6200 to df06be9 Compare June 14, 2026 20:35
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: surface thrown generateStaticParams/getStaticPaths errors + fail the build

I verified the full chain against the source and ran the relevant suites locally. The core fix is correct and well-scoped, and the follow-up that makes these errors fatal in all modes is a sound parity improvement. Details:

Verified correct

  • Endpoint discriminator is right: app-prerender-endpoints.ts returns notFoundResponse() (404) for disabled/stale-secret and jsonResponse({ error: String(error) }, 500) only from the try/catch wrapping the user function. So res.status === 500 genuinely means the user's generateStaticParams/getStaticPaths threw. Branching on it (and keeping warn-skip for non-500) is exactly the right cut.
  • App Router: the 500 throw at prerender.ts:1070 propagates up through the dynamic-route block and is caught by the existing per-route collector at prerender.ts:1285. Correct.
  • Pages Router: the new try/catch at prerender.ts:758 is narrowly scoped around just the getStaticPaths call, converting a throw into a per-route error instead of crashing the loop. Good — and it correctly does not broaden the existing missing-params normalization error (prerender.ts:802), which stays non-fatal, so the missing-params test still passes.
  • vp check clean; tests/prerender.test.ts (incl. export-mode) green locally. Export-mode still produces Static export failed (its errors aren't fatal), so the new fatal check at run-prerender.ts:348 doesn't shadow it.

Issues raised inline

  1. (test gap) No test exercises the actual run-prerender.ts fatal-throw — the headline "fail the build in every mode" claim is unverified end-to-end. The new tests only assert prerenderApp/prerenderPages return { fatal: true }.
  2. (behavior breadth) The App Router catch is broad: a transport/fetch failure inside resolveParentParams/generateStaticParamsFn (e.g. prod server crash, connection reset) is now flagged fatal and fails the build in all modes, even though it isn't a user generateStaticParams error. Worth confirming this is intended.
  3. (minor) parsePrerenderEndpointError falls back to the raw JSON body when { error } is empty/missing, leaking {"error":""} into the message.

None are blocking; (1) is the one I'd most like addressed before merge.

Comment thread tests/prerender.test.ts
const route = result.routes.find((r) => r.route.includes("slug"));
// `fatal: true` makes run-prerender fail the build in default mode too,
// matching Next.js (not just a visible-but-non-fatal error). #1982
expect(route).toMatchObject({ status: "error", fatal: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: these tests assert that prerenderApp/prerenderPages return { status: "error", fatal: true }, but nothing exercises the actual build-failing behavior in run-prerender.ts:348 — i.e. that runPrerender now throws Prerender failed: ... in default mode. That throw is the headline change of this PR ("fail the build in every mode"), yet it's only covered transitively.

A cheap focused test would feed a synthetic fatal route through runPrerender and assert rejects.toThrow(/Prerender failed/) in mode: "default", mirroring the existing output: 'export' wiring block at line 1399. Without it, a regression that drops the fatal filter (or stops setting fatal) would still pass this suite.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — added direct coverage. Extracted the fatal-route gate into an exported assertNoFatalPrerenderRoutes(routes) (run-prerender.ts) that runPrerender now calls, and added two focused tests feeding it synthetic routes: a { status: "error", fatal: true } route → toThrow(/Prerender failed/), and a skipped SSR route + a non-fatal error → not.toThrow(). So a regression that drops the fatal filter (or stops setting fatal) now fails the suite. Pushed in ee4bcff2.

Comment thread packages/vinext/src/build/prerender.ts Outdated
route: route.pattern,
status: "error",
error: `Failed to call generateStaticParams(): ${detail}`,
fatal: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catch wraps the entire dynamic-route block — resolveParentParams, every generateStaticParamsFn call, the "no params object" throw (line 1237), and buildUrlFromParams. Flagging all of them fatal: true means a transport/fetch failure (prod server crash, connection reset, ECONNREFUSED mid-prerender) — which throws a TypeError from fetch, not a 500 — now fails the build in every mode too, even though it isn't a thrown user generateStaticParams.

That may be acceptable (failing is arguably safer than silently shipping incomplete prerenders), but it's broader than "a thrown generateStaticParams is fatal" as described. If you want to scope fatal strictly to user-function throws, gate it on the 500 path (e.g. tag the error thrown at line 1070 and only mark fatal for that), so genuine infra errors stay non-fatal in default mode. At minimum worth a comment that any error in this block is now treated as fatal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — scoped fatal strictly to user-function throws. The 500 path now throws a dedicated PrerenderUserFunctionError (not a plain Error), and both catch sites only set fatal: true when e instanceof PrerenderUserFunctionError. So a transport/fetch failure (prod server crash, ECONNREFUSED) is still recorded as a per-route error but stays non-fatal — it only fails the build under output: 'export', exactly as before. Pushed in ee4bcff2.

const parsed = JSON.parse(text) as { error?: unknown };
if (parsed && typeof parsed.error === "string" && parsed.error.length > 0) {
return parsed.error;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: when the 500 body is valid JSON but error is empty or non-string (e.g. {"error":""}), this falls through to return text, surfacing the raw {"error":""} JSON as the user-facing message. Consider returning the "Unknown prerender endpoint error" fallback in that case instead of echoing the JSON envelope.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — parsePrerenderEndpointError now detects our JSON envelope ("error" in parsed) and returns the "Unknown prerender endpoint error" fallback when error is empty or non-string, instead of echoing the raw {"error":""}. A non-JSON body still falls back to the raw text. Pushed in ee4bcff2.

@ask-bonk

ask-bonk Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2017 (review)

Summary

I reviewed PR #2017, verified the full chain of changes against the source, and ran the relevant test suites locally (tests/prerender.test.ts green, vp check clean). The core fix is correct and well-scoped, and the fatal follow-up is a sound Next.js parity improvement.

Verified correct:

  • The 500-vs-404 discriminator is right — the endpoint only returns 500 from the try/catch around the user function, so it genuinely means a thrown generateStaticParams/getStaticPaths.
  • App Router throw propagates to the existing per-route collector; Pages Router's new try/catch is narrowly scoped and doesn't accidentally re-flag the existing non-fatal missing-params normalization error.
  • Export-mode behavior is preserved (its errors aren't fatal, so Static export failed still fires correctly).

Issues raised inline (none blocking):

  1. Test gap (most important): no test exercises the actual run-prerender.ts fatal-throw — the headline "fail the build in every mode" behavior is only verified transitively via { fatal: true } assertions. A focused runPrerender test asserting rejects.toThrow(/Prerender failed/) in default mode would close this.
  2. Behavior breadth: the App Router catch is broad, so a transport/fetch failure (server crash, ECONNREFUSED) is now flagged fatal and fails the build in all modes, not just a thrown user function. Worth confirming this is intended vs. gating fatal on the 500 path specifically.
  3. Minor: parsePrerenderEndpointError leaks the raw {"error":""} JSON when the error field is empty.

Posted as a non-blocking COMMENT review since the code itself is sound.

github run

…rors

The build-time prerender proxies that fetch the static-params (App Router)
and pages-static-paths (Pages Router) endpoints treated every non-ok
response identically: they logged a misleading "stale or missing prerender
secret" warning and returned the no-params sentinel, discarding the response
body. But the endpoint returns 404 for the genuine disabled/secret case and
500 (with the real error in its JSON body) when the user's
generateStaticParams/getStaticPaths throws. Swallowing the 500 silently
dropped the route from prerendering and let the default-mode build "succeed"
while shipping an app missing pre-rendered pages, blaming a non-existent
secret problem.

Branch on status: a 500 throws the real error (parsed from the body). App
Router's existing per-route collector turns it into a "Failed to call
generateStaticParams(): ..." error; the Pages Router getStaticPaths call is
now wrapped to produce the same per-route error instead of crashing. Non-500
responses keep the warn-and-skip path, so the legitimate secret/disabled case
is unchanged. This matches Next.js, which fails the build with the real error.

Fixes cloudflare#1982

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Xplod13
Xplod13 force-pushed the fix/prerender-surface-static-params-errors branch from df06be9 to ee4bcff Compare June 14, 2026 21:46
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: surface thrown generateStaticParams/getStaticPaths errors

Re-reviewed the final state (commit ee4bcff2). The fix is correct and well-scoped, and all three concerns from prior reviews have been properly addressed:

  • Fatal scoping: fatal: true is now gated strictly on e instanceof PrerenderUserFunctionError (prerender.ts:777, 1300), so a transport/fetch failure stays non-fatal and only fails under output: 'export'. Verified against the endpoint — app-prerender-endpoints.ts returns 500 only from the try/catch around the user function (lines 72, 101), so res.status === 500 is the right discriminator. The 400/404 paths are distinct and keep warn-and-skip.
  • Empty-error fallback: parsePrerenderEndpointError now returns "Unknown prerender endpoint error" for an empty/non-string error field instead of echoing {"error":""} (prerender.ts:91-103).
  • Test gap: assertNoFatalPrerenderRoutes is extracted and directly unit-tested for both the fatal-throw and the non-fatal/skip cases, so a regression that drops the fatal filter would now fail the suite.

Verified locally: tests/prerender.test.ts -t "1982" green (5 pass), vp check clean on both touched files.

Two minor, non-blocking observations below.

results.push({
route: route.pattern,
status: "error",
error: `Failed to call getStaticPaths(): ${(e as Error).message}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor consistency: this catch uses (e as Error).message, but the other error-recording sites in this file gate on config.enablePrerenderSourceMaps to optionally include the stack via getErrorMessageWithStack — see prerender.ts:874 (SSG render) and prerender.ts:1291 (App Router generateStaticParams). config is in scope here too.

In practice the stack matters less here since the message is a stringified error coming back over HTTP from the endpoint (not a local throw with a useful stack), so this is purely a consistency nit, not a correctness issue.

error: `Failed to call generateStaticParams(): ${detail}`,
// Only a thrown user generateStaticParams (a 500 from the endpoint) is
// fatal to the build; transport/fetch failures stay non-fatal. #1982
...(e instanceof PrerenderUserFunctionError ? { fatal: true as const } : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Attribution note (not blocking): this catch also wraps resolveParentParams (prerender.ts:1191), which invokes a parent segment's generateStaticParamsFn at prerender.ts:522. If a parent route's generateStaticParams throws, it surfaces as a PrerenderUserFunctionError here and is attributed to the child route's pattern (Failed to call generateStaticParams(): … for the child), even though the throw originated in the parent.

The build still correctly fails (the error is a genuine user-function throw and is rightly fatal), so this is just a message-attribution quirk worth being aware of — not a behavioral bug.

@ask-bonk

ask-bonk Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2017 (review)

Summary

I re-reviewed PR #2017 at its final state (commit ee4bcff2), verifying the full chain against the source and running the relevant tests locally. The fix is correct and well-scoped, and all three concerns from the prior review rounds were properly addressed by the author:

Verified correct:

  • 500 discriminator: app-prerender-endpoints.ts returns 500 only from the try/catch around the user function (lines 72, 101); 400/404 are distinct. So res.status === 500 genuinely means a thrown generateStaticParams/getStaticPaths.
  • Fatal scoping: fatal: true is now gated strictly on e instanceof PrerenderUserFunctionError (prerender.ts:777, 1300), so transport/fetch failures stay non-fatal — closing the breadth concern from the prior review.
  • Empty-error fallback: parsePrerenderEndpointError returns a generic message instead of echoing {"error":""}.
  • Test gap closed: assertNoFatalPrerenderRoutes is extracted and directly unit-tested for both fatal-throw and non-fatal/skip cases.
  • tests/prerender.test.ts -t "1982" green (5 pass), vp check clean.

Two minor, non-blocking observations flagged inline:

  1. The new Pages Router getStaticPaths catch uses (e as Error).message instead of the config.enablePrerenderSourceMaps/getErrorMessageWithStack pattern used elsewhere in the file — a pure consistency nit (the message is an over-HTTP string with no useful local stack anyway).
  2. The App Router catch also wraps resolveParentParams, so a thrown parent-segment generateStaticParams gets attributed to the child route's pattern in the message. The build still correctly fails; just a message-attribution quirk.

Posted as a non-blocking COMMENT review since the code is sound and ready to merge.

github run

@james-elicx
james-elicx merged commit 37e839e into cloudflare:main Jun 14, 2026
52 checks passed
@Xplod13
Xplod13 deleted the fix/prerender-surface-static-params-errors branch June 16, 2026 17:09
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.

2 participants