Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(nextjs): Don't report Next.js prerender control flow errors#23691
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export async function GET() { | ||
| return Response.json({ value: 'hanging-fetch-data' }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export default function Loading() { | ||
| return <div id="sentinel-loading">Loading...</div>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import * as Sentry from '@sentry/nextjs'; | ||
| // Captures an error tagged with a caller-supplied unique token. Tests use this as a drain marker: the | ||
| // token guarantees a cache miss, so the capture always happens on request, and its arrival proves the | ||
| // event pipeline has drained past every earlier request. | ||
| export default async function Page({ params }: { params: Promise<{ token: string }> }) { | ||
| const { token } = await params; | ||
| Sentry.captureException(new Error(`error-sentinel-${token}`)); | ||
| return <p id="sentinel">{token}</p>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export default function Loading() { | ||
| return <div id="loading">Loading...</div>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| // This `fetch()` deliberately has no cache configuration. Under Cache Components, Next.js does not | ||
| // issue such a request during a prerender - it hands out a promise that never settles and rejects it | ||
| // with a `HANGING_PROMISE_REJECTION` digest once the prerender is aborted. That rejection surfaces in | ||
| // this component and therefore in the Sentry server component wrapper, which must not report it. | ||
| export default async function Page() { | ||
| const response = await fetch('http://localhost:3030/api/hanging-fetch-data'); | ||
| const data = (await response.json()) as { value: string }; | ||
| return <p id="fetched-value">{data.value}</p>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; | ||
| const HANGING_PROMISE_DIGEST_MESSAGE = 'rejects when the prerender is complete'; | ||
| // Under Cache Components, Next.js aborts prerenders by rejecting the promises it handed out for | ||
| // uncached `fetch()` calls. React discards those rejections - they never affect the response - so the | ||
| // Sentry wrappers must not report them. See https://github.com/getsentry/sentry-javascript/issues/23592 | ||
| // | ||
| // Note this only exercises the regression under the webpack variant: server components are wrapped by | ||
| // `wrappingLoader`, which Turbopack builds do not run, so there is no wrapper to observe the rejection | ||
| // there. Under Turbopack the test still asserts the route renders and reports no errors. | ||
| test('does not capture hanging prerender promise rejections on a runtime prefetch', async ({ page, request }) => { | ||
| const capturedHangingPromiseErrors: string[] = []; | ||
| void waitForError('nextjs-16-cacheComponents', errorEvent => { | ||
| const value = errorEvent.exception?.values?.[0]?.value ?? ''; | ||
| if (value.includes(HANGING_PROMISE_DIGEST_MESSAGE)) { | ||
| capturedHangingPromiseErrors.push(value); | ||
| } | ||
| return false; | ||
| }); | ||
| // `Next-Router-Prefetch: 2` is what the Next.js router sends for a runtime prefetch. It makes Next.js | ||
| // run a prerender at request time, which is what produces the hanging promise rejection. A plain | ||
| // document request only replays the shell that was prerendered at build time and would not trigger it. | ||
| const prefetchResponse = await request.get('/hanging-fetch', { | ||
| headers: { RSC: '1', 'Next-Router-Prefetch': '2' }, | ||
| }); | ||
| expect(prefetchResponse.ok()).toBe(true); | ||
| const serverTransactionPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { | ||
| return ( | ||
| transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /hanging-fetch' | ||
| ); | ||
| }); | ||
| await page.goto('/hanging-fetch'); | ||
| await expect(page.locator('#fetched-value')).toHaveText('hanging-fetch-data'); | ||
| expect(await serverTransactionPromise).toBeDefined(); | ||
| // Drain marker instead of a sleep: request a route that deliberately captures an error, tagged with a | ||
| // token unique to this run so it can never be served from cache. It is requested strictly after the | ||
| // prefetch, and the SDK flushes per request, so once this error arrives any error the prefetch had | ||
| // captured must already have arrived too. That makes the assertion below "nothing was captured" | ||
| // rather than "nothing had been captured yet". It doubles as a check that errors do flow at all. | ||
| const token = `${Date.now()}`; | ||
| const sentinelPromise = waitForError('nextjs-16-cacheComponents', errorEvent => { | ||
| return errorEvent.exception?.values?.[0]?.value === `error-sentinel-${token}`; | ||
| }); | ||
| await page.goto(`/error-sentinel/${token}`); | ||
| await expect(page.locator('#sentinel')).toHaveText(token); | ||
| await sentinelPromise; | ||
| expect(capturedHangingPromiseErrors).toEqual([]); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,26 +1,62 @@ | ||
| import { isError } from '@sentry/core'; | ||
| // Next.js nests the "real" reason in `cause` when an error crosses certain boundaries, and | ||
| // `unstable_rethrow` walks that chain. The cap guards against self-referencing causes. | ||
| const MAX_CAUSE_DEPTH = 5; | ||
| function hasDigest(subject: unknown, predicate: (digest: string) => boolean, depth = 0): boolean { | ||
| if (!isError(subject)) { | ||
| return false; | ||
| } | ||
| const digest = (subject as Error & { digest?: unknown }).digest; | ||
| if (typeof digest === 'string' && predicate(digest)) { | ||
| return true; | ||
| } | ||
| if (depth < MAX_CAUSE_DEPTH && 'cause' in subject) { | ||
| return hasDigest(subject.cause, predicate, depth + 1); | ||
| } | ||
| return false; | ||
| } | ||
| /** | ||
| * Determines whether input is a Next.js not-found error. | ||
| * https://beta.nextjs.org/docs/api-reference/notfound#notfound | ||
| */ | ||
| export function isNotFoundNavigationError(subject: unknown): boolean { | ||
chargome marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| return ( | ||
| isError(subject) && | ||
| ['NEXT_NOT_FOUND', 'NEXT_HTTP_ERROR_FALLBACK;404'].includes( | ||
| (subject as Error & { digest?: unknown }).digest as string, | ||
| ) | ||
| ); | ||
| return hasDigest(subject, digest => ['NEXT_NOT_FOUND', 'NEXT_HTTP_ERROR_FALLBACK;404'].includes(digest)); | ||
| } | ||
| /** | ||
| * Determines whether input is a Next.js redirect error. | ||
| * https://beta.nextjs.org/docs/api-reference/redirect#redirect | ||
| */ | ||
| export function isRedirectNavigationError(subject: unknown): boolean { | ||
| return ( | ||
| isError(subject) && | ||
| typeof (subject as Error & { digest?: unknown }).digest === 'string' && | ||
| (subject as Error & { digest: string }).digest.startsWith('NEXT_REDIRECT;') // a redirect digest looks like "NEXT_REDIRECT;[redirect path]" | ||
| ); | ||
| // a redirect digest looks like "NEXT_REDIRECT;[redirect path]" | ||
| return hasDigest(subject, digest => digest.startsWith('NEXT_REDIRECT;')); | ||
| } | ||
| const PRERENDER_CONTROL_FLOW_DIGESTS = [ | ||
| // Next.js hands out promises that never settle (e.g. from `fetch()` under Cache Components) and rejects | ||
| // them once the prerender is aborted. React discards them - anything else observing them must ignore them. | ||
| 'HANGING_PROMISE_REJECTION', | ||
| // Thrown to abort a prerender the moment dynamic data is accessed. | ||
| 'NEXT_PRERENDER_INTERRUPTED', | ||
| // Thrown to bail out of static generation into dynamic rendering. | ||
| 'DYNAMIC_SERVER_USAGE', | ||
| // Thrown by `next/dynamic` to bail out of SSR into client-side rendering. | ||
| 'BAILOUT_TO_CLIENT_SIDE_RENDERING', | ||
| ]; | ||
| /** | ||
| * Determines whether input is one of the errors Next.js throws to steer rendering rather than to signal a failure. | ||
| * | ||
| * This mirrors the non-navigation half of Next.js' `unstable_rethrow`, which is the contract any code wrapping | ||
| * user land in a `try`/`catch` has to honor. | ||
| * https://nextjs.org/docs/app/api-reference/functions/unstable_rethrow | ||
| */ | ||
| export function isPrerenderControlFlowError(subject: unknown): boolean { | ||
| return hasDigest(subject, digest => PRERENDER_CONTROL_FLOW_DIGESTS.includes(digest)); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.