diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/hanging-fetch-data/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-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-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-cacheComponents/app/hanging-fetch/loading.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/hanging-fetch/loading.tsx new file mode 100644 index 000000000000..b965fb5c3cf8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-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-cacheComponents/app/hanging-fetch/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/hanging-fetch/page.tsx new file mode 100644 index 000000000000..2beab3b0ebee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-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-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json index 63d8602ff6df..50628e2b478c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-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-cacheComponents (webpack)", + "assert-command": "pnpm test:assert-webpack" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/hangingPromiseRejection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/hangingPromiseRejection.spec.ts new file mode 100644 index 000000000000..500f2647f434 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/hangingPromiseRejection.spec.ts @@ -0,0 +1,54 @@ +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'; + +function collectHangingPromiseErrors(): { events: unknown[] } { + const collected: { events: unknown[] } = { events: [] }; + + void waitForError('nextjs-16-cacheComponents', errorEvent => { + const value = errorEvent.exception?.values?.[0]?.value ?? ''; + return value.includes(HANGING_PROMISE_DIGEST_MESSAGE); + }).then(event => { + collected.events.push(event); + }); + + return collected; +} + +// 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 collected = collectHangingPromiseErrors(); + + // `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'); + + // Waiting for the transaction proves the SDK was wired up and flushing events for this route, so an + // empty error list below means "nothing was captured" rather than "nothing was listening". + const serverTransaction = await serverTransactionPromise; + expect(serverTransaction).toBeDefined(); + + await page.waitForTimeout(5000); + + expect(collected.events).toEqual([]); +}); diff --git a/packages/nextjs/src/common/captureRequestError.ts b/packages/nextjs/src/common/captureRequestError.ts index a34a868859ac..e0e1d094a211 100644 --- a/packages/nextjs/src/common/captureRequestError.ts +++ b/packages/nextjs/src/common/captureRequestError.ts @@ -1,5 +1,6 @@ import type { RequestEventData } from '@sentry/core'; import { captureException, headersToDict, withScope } from '@sentry/core'; +import { isPrerenderControlFlowError } from './nextNavigationErrorUtils'; import { flushSafelyWithTimeout, waitUntil } from './utils/responseEnd'; type RequestInfo = { @@ -18,6 +19,10 @@ type ErrorContext = { * Reports errors passed to the the Next.js `onRequestError` instrumentation hook. */ export function captureRequestError(error: unknown, request: RequestInfo, errorContext: ErrorContext): void { + if (isPrerenderControlFlowError(error)) { + return; + } + withScope(scope => { scope.setSDKProcessingMetadata({ normalizedRequest: { diff --git a/packages/nextjs/src/common/nextNavigationErrorUtils.ts b/packages/nextjs/src/common/nextNavigationErrorUtils.ts index b7f4d1de9b4c..94960cc32ca9 100644 --- a/packages/nextjs/src/common/nextNavigationErrorUtils.ts +++ b/packages/nextjs/src/common/nextNavigationErrorUtils.ts @@ -1,16 +1,32 @@ 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 { - 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)); } /** @@ -18,9 +34,29 @@ export function isNotFoundNavigationError(subject: unknown): boolean { * 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)); } diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 295b06548af4..d90678ded6ed 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -9,7 +9,11 @@ import { winterCGHeadersToDict, } from '@sentry/core'; import type { GenerationFunctionContext } from '../common/types'; -import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; +import { + isNotFoundNavigationError, + isPrerenderControlFlowError, + isRedirectNavigationError, +} from './nextNavigationErrorUtils'; import { flushSafelyWithTimeout, waitUntil } from './utils/responseEnd'; /** @@ -45,34 +49,39 @@ export function wrapGenerationFunctionWithSentry a error => { const span = getActiveSpan(); const { componentRoute, componentType, generationFunctionIdentifier } = context; - let shouldCapture = true; isolationScope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`); - if (span) { - if (isNotFoundNavigationError(error)) { - // We don't want to report "not-found"s - shouldCapture = false; - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } else if (isRedirectNavigationError(error)) { - // We don't want to report redirects - shouldCapture = false; - span.setStatus({ code: SPAN_STATUS_OK }); - } else { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - } + // Next.js uses thrown errors for control flow. Whether or not we happen to have an active span here + // must not decide if we report them, so these checks deliberately run outside of the span handling. + if (isNotFoundNavigationError(error)) { + // We don't want to report "not-found"s + span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + return; } - if (shouldCapture) { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.function.nextjs.generation_function', - data: { - function: generationFunctionIdentifier, - }, - }, - }); + if (isRedirectNavigationError(error)) { + // We don't want to report redirects + span?.setStatus({ code: SPAN_STATUS_OK }); + return; + } + + if (isPrerenderControlFlowError(error)) { + // Next.js aborts prerenders by rejecting the promises it handed out. React discards those rejections, + // so they are expected, do not affect the response, and must not be reported. + return; } + + span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + + captureException(error, { + mechanism: { + handled: false, + type: 'auto.function.nextjs.generation_function', + data: { + function: generationFunctionIdentifier, + }, + }, + }); }, () => { waitUntil(flushSafelyWithTimeout()); diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index c0311faf0244..73af164de3a7 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -16,7 +16,11 @@ import { withIsolationScope, withScope, } from '@sentry/core'; -import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; +import { + isNotFoundNavigationError, + isPrerenderControlFlowError, + isRedirectNavigationError, +} from './nextNavigationErrorUtils'; import type { RouteHandlerContext } from './types'; import { flushSafelyWithTimeout, waitUntil } from './utils/responseEnd'; import { commonObjectToIsolationScope } from './utils/tracingUtils'; @@ -89,6 +93,9 @@ export function wrapRouteHandlerWithSentry any>( if (rootSpan) { setHttpStatus(rootSpan, 404); } + } else if (isPrerenderControlFlowError(error)) { + // Next.js aborts prerenders by rejecting the promises it handed out. React discards those + // rejections, so they are expected, do not affect the response, and must not be reported. } else { const errorStatus = { code: SPAN_STATUS_ERROR, message: 'internal_error' } as const; activeSpan?.setStatus(errorStatus); diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index be23decd6014..6deaef200f67 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -8,7 +8,11 @@ import { SPAN_STATUS_OK, winterCGHeadersToDict, } from '@sentry/core'; -import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; +import { + isNotFoundNavigationError, + isPrerenderControlFlowError, + isRedirectNavigationError, +} from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; @@ -40,31 +44,36 @@ export function wrapServerComponentWithSentry any> error => { const span = getActiveSpan(); const { componentRoute, componentType } = context; - let shouldCapture = true; isolationScope.setTransactionName(`${componentType} Server Component (${componentRoute})`); - if (span) { - if (isNotFoundNavigationError(error)) { - // We don't want to report "not-found"s - shouldCapture = false; - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } else if (isRedirectNavigationError(error)) { - // We don't want to report redirects - shouldCapture = false; - span.setStatus({ code: SPAN_STATUS_OK }); - } else { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - } + // Next.js uses thrown errors for control flow. Whether or not we happen to have an active span here + // must not decide if we report them, so these checks deliberately run outside of the span handling. + if (isNotFoundNavigationError(error)) { + // We don't want to report "not-found"s + span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + return; + } + + if (isRedirectNavigationError(error)) { + // We don't want to report redirects + span?.setStatus({ code: SPAN_STATUS_OK }); + return; } - if (shouldCapture) { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.function.nextjs.server_component', - }, - }); + if (isPrerenderControlFlowError(error)) { + // Next.js aborts prerenders by rejecting the promises it handed out. React discards those rejections, + // so they are expected, do not affect the response, and must not be reported. + return; } + + span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + + captureException(error, { + mechanism: { + handled: false, + type: 'auto.function.nextjs.server_component', + }, + }); }, () => { waitUntil(flushSafelyWithTimeout()); diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 452887f0d8f4..33d417507b39 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -9,6 +9,7 @@ import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sent import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; +import { isPrerenderControlFlowError } from '../common/nextNavigationErrorUtils'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { isBuild } from '../common/utils/isBuild'; import { isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd'; @@ -240,6 +241,13 @@ export function init(options: NodeOptions): NodeClient | undefined { return null; } + if (isPrerenderControlFlowError(originalException)) { + // Next.js aborts prerenders by rejecting the promises it handed out (e.g. `fetch()` under Cache + // Components) and throws to bail out of static rendering. These never reach the user, so drop them + // here as well - the wrappers cannot cover every path they escape through. + return null; + } + // We don't want to capture suspense errors as they are simply used by React/Next.js for control flow const exceptionMessage = event.exception?.values?.[0]?.value; if ( diff --git a/packages/nextjs/test/common/nextNavigationErrorUtils.test.ts b/packages/nextjs/test/common/nextNavigationErrorUtils.test.ts new file mode 100644 index 000000000000..072893b8f53b --- /dev/null +++ b/packages/nextjs/test/common/nextNavigationErrorUtils.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { + isNotFoundNavigationError, + isPrerenderControlFlowError, + isRedirectNavigationError, +} from '../../src/common/nextNavigationErrorUtils'; + +function errorWithDigest(digest: string, cause?: unknown): Error { + return Object.assign(new Error('some message'), { digest, cause }); +} + +describe('isNotFoundNavigationError', () => { + it.each(['NEXT_NOT_FOUND', 'NEXT_HTTP_ERROR_FALLBACK;404'])('detects the %s digest', digest => { + expect(isNotFoundNavigationError(errorWithDigest(digest))).toBe(true); + }); + + it('does not detect unrelated errors', () => { + expect(isNotFoundNavigationError(new Error('boom'))).toBe(false); + expect(isNotFoundNavigationError(errorWithDigest('NEXT_REDIRECT;/foo'))).toBe(false); + expect(isNotFoundNavigationError({ digest: 'NEXT_NOT_FOUND' })).toBe(false); + }); +}); + +describe('isRedirectNavigationError', () => { + it('detects a redirect digest', () => { + expect(isRedirectNavigationError(errorWithDigest('NEXT_REDIRECT;/some-path'))).toBe(true); + }); + + it('does not detect unrelated errors', () => { + expect(isRedirectNavigationError(errorWithDigest('NEXT_NOT_FOUND'))).toBe(false); + expect(isRedirectNavigationError(new Error('boom'))).toBe(false); + }); +}); + +describe('isPrerenderControlFlowError', () => { + it.each([ + 'HANGING_PROMISE_REJECTION', + 'NEXT_PRERENDER_INTERRUPTED', + 'DYNAMIC_SERVER_USAGE', + 'BAILOUT_TO_CLIENT_SIDE_RENDERING', + ])('detects the %s digest', digest => { + expect(isPrerenderControlFlowError(errorWithDigest(digest))).toBe(true); + }); + + it('detects a control flow error nested in a cause chain', () => { + const nested = new Error('outer', { cause: errorWithDigest('HANGING_PROMISE_REJECTION') }); + expect(isPrerenderControlFlowError(nested)).toBe(true); + }); + + it('does not detect navigation errors or regular errors', () => { + expect(isPrerenderControlFlowError(errorWithDigest('NEXT_REDIRECT;/foo'))).toBe(false); + expect(isPrerenderControlFlowError(errorWithDigest('NEXT_NOT_FOUND'))).toBe(false); + expect(isPrerenderControlFlowError(new Error('boom'))).toBe(false); + expect(isPrerenderControlFlowError(undefined)).toBe(false); + }); + + it('terminates on a self-referencing cause chain', () => { + const error = new Error('boom'); + (error as Error & { cause: unknown }).cause = error; + expect(isPrerenderControlFlowError(error)).toBe(false); + }); +}); diff --git a/packages/nextjs/test/common/wrapServerComponentWithSentry.test.ts b/packages/nextjs/test/common/wrapServerComponentWithSentry.test.ts new file mode 100644 index 000000000000..aed2553c7baa --- /dev/null +++ b/packages/nextjs/test/common/wrapServerComponentWithSentry.test.ts @@ -0,0 +1,79 @@ +import type { Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerComponentWithSentry } from '../../src/common/wrapServerComponentWithSentry'; + +const context = { componentRoute: '/rdva', componentType: 'Page' }; + +function mockActiveSpan(span: Span | undefined): { setStatus: ReturnType } { + const setStatus = vi.fn(); + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(span ? ({ setStatus } as unknown as Span) : undefined); + return { setStatus }; +} + +async function runWrapped(error: unknown): Promise { + const wrapped = wrapServerComponentWithSentry(async () => { + throw error; + }, context); + + await expect(wrapped()).rejects.toBe(error); +} + +describe('wrapServerComponentWithSentry', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('captures regular errors and marks the active span as errored', async () => { + const captureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => ''); + const { setStatus } = mockActiveSpan({} as Span); + + const error = new Error('boom'); + await runWrapped(error); + + expect(captureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.function.nextjs.server_component' }, + }); + expect(setStatus).toHaveBeenCalledWith({ code: SentryCore.SPAN_STATUS_ERROR, message: 'internal_error' }); + }); + + it.each([ + ['not-found', 'NEXT_NOT_FOUND'], + ['redirect', 'NEXT_REDIRECT;/somewhere'], + ['hanging prerender promise', 'HANGING_PROMISE_REJECTION'], + ['prerender interruption', 'NEXT_PRERENDER_INTERRUPTED'], + ['dynamic server usage', 'DYNAMIC_SERVER_USAGE'], + ['bailout to client side rendering', 'BAILOUT_TO_CLIENT_SIDE_RENDERING'], + ])('does not capture %s control flow errors', async (_name, digest) => { + const captureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => ''); + mockActiveSpan({} as Span); + + await runWrapped(Object.assign(new Error('control flow'), { digest })); + + expect(captureException).not.toHaveBeenCalled(); + }); + + it.each(['NEXT_NOT_FOUND', 'NEXT_REDIRECT;/somewhere', 'HANGING_PROMISE_REJECTION'])( + 'does not capture the %s control flow error when there is no active span', + async digest => { + const captureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => ''); + mockActiveSpan(undefined); + + await runWrapped(Object.assign(new Error('control flow'), { digest })); + + expect(captureException).not.toHaveBeenCalled(); + }, + ); + + it('still captures regular errors when there is no active span', async () => { + const captureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => ''); + mockActiveSpan(undefined); + + const error = new Error('boom'); + await runWrapped(error); + + expect(captureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.function.nextjs.server_component' }, + }); + }); +});