diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/[id]/page.tsx new file mode 100644 index 000000000000..18cb7fcf532d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/[id]/page.tsx @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/nextjs'; + +// Only `test` is prerendered at build time. `exception` and `message` are generated +// on-demand at request time. +export function generateStaticParams() { + return [{ id: 'test' }]; +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + if (id === 'exception') { + Sentry.captureException(new Error('Test error from cache components page')); + return

Error captured for id exception

; + } + + if (id === 'message') { + Sentry.captureMessage('Test message from cache components page'); + return

Message captured for id message

; + } + + return

Hello, {id}!

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/hanging-fetch-data/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/hanging-fetch-data/route.ts new file mode 100644 index 000000000000..8906fe0372d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/hanging-fetch-data/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ value: 'hanging-fetch-data' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/capture-metadata/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/capture-metadata/page.tsx new file mode 100644 index 000000000000..15d10d763a6d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/capture-metadata/page.tsx @@ -0,0 +1,15 @@ +import { captureException } from '@sentry/nextjs'; +import type { Metadata } from 'next'; + +/** + * Calling captureException synchronously inside generateMetadata + * during `next build` prerender (cacheComponents). uuid4 -> crypto.randomUUID() runs + */ +export const generateMetadata = (): Metadata => { + captureException(new Error('diagnostic: data missing for this page')); + return { title: 'capture-metadata' }; +}; + +export default function Page() { + return

capture-metadata

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/error-sentinel/[token]/loading.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/error-sentinel/[token]/loading.tsx new file mode 100644 index 000000000000..976ab90e6d75 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/error-sentinel/[token]/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
Loading...
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/error-sentinel/[token]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/error-sentinel/[token]/page.tsx new file mode 100644 index 000000000000..4ff6415c3414 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/error-sentinel/[token]/page.tsx @@ -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

{token}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/hanging-fetch/loading.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/hanging-fetch/loading.tsx new file mode 100644 index 000000000000..b965fb5c3cf8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/hanging-fetch/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
Loading...
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/hanging-fetch/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/hanging-fetch/page.tsx new file mode 100644 index 000000000000..2beab3b0ebee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/hanging-fetch/page.tsx @@ -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

{data.value}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json index ac2f90c4db53..1418dab1cf4f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json @@ -46,6 +46,12 @@ "extends": "../../package.json" }, "sentryTest": { - "//": "TODO: Add variants for webpack once supported" + "variants": [ + { + "build-command": "pnpm test:build-webpack", + "label": "nextjs-16-streaming-cacheComponents (webpack)", + "assert-command": "pnpm test:assert-webpack" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts index 106e430c8177..49f2f49cb5b2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForStreamedSpan, waitForStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; test('Should render cached component', async ({ page }) => { const spansPromise = waitForStreamedSpans('nextjs-16-streaming-cacheComponents', spans => { @@ -49,6 +49,34 @@ test('Should generate metadata', async ({ page }) => { await expect(page).toHaveTitle('Cache Components Metadata Test'); }); +// Capturing an event inside a Server Component that is (re)generated at request time must not +// trip Next.js Cache Components prerender guards (`new Date()` / `crypto`). +test('Should capture an exception from an on-demand generated Server Component', async ({ page }) => { + const errorPromise = waitForError('nextjs-16-streaming-cacheComponents', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Test error from cache components page'; + }); + + await page.goto('/exception'); + + await expect(page.locator('#result')).toHaveText('Error captured for id exception'); + + const error = await errorPromise; + expect(error.exception?.values?.[0]?.value).toBe('Test error from cache components page'); +}); + +test('Should capture a message from an on-demand generated Server Component', async ({ page }) => { + const messagePromise = waitForError('nextjs-16-streaming-cacheComponents', errorEvent => { + return errorEvent.message === 'Test message from cache components page'; + }); + + await page.goto('/message'); + + await expect(page.locator('#result')).toHaveText('Message captured for id message'); + + const message = await messagePromise; + expect(message.message).toBe('Test message from cache components page'); +}); + test('Should generate metadata async', async ({ page }) => { const spansPromise = waitForStreamedSpans('nextjs-16-streaming-cacheComponents', spans => { return spans.some( @@ -96,3 +124,10 @@ test('Prerendered shell does not stitch the pageload onto a stale trace', async expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); expect(await page.locator('meta[name="baggage"]').count()).toBe(0); }); + +test('Should prerender a page that captures an exception in generateMetadata', async ({ page }) => { + await page.goto('/capture-metadata'); + + await expect(page).toHaveTitle('capture-metadata'); + await expect(page.locator('h1')).toHaveText('capture-metadata'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/hangingPromiseRejection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/hangingPromiseRejection.spec.ts new file mode 100644 index 000000000000..2842a651d03f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/hangingPromiseRejection.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } 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-streaming-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 serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { + return span.name === 'GET /hanging-fetch' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + await page.goto('/hanging-fetch'); + await expect(page.locator('#fetched-value')).toHaveText('hanging-fetch-data'); + + expect(await serverSpanPromise).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-streaming-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([]); +});