diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.gitignore index dd146b53d966..e0fc7baa1ebc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.gitignore +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.gitignore @@ -42,3 +42,6 @@ next-env.d.ts # Sentry Config File .env.sentry-build-plugin + +# OpenNext build output +/.open-next/ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/prefetch-overlap/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/prefetch-overlap/[id]/page.tsx new file mode 100644 index 000000000000..0e176752416b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/prefetch-overlap/[id]/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react'; + +export default function Page({ params }: { params: Promise<{ id: string }> }) { + return ( + Loading...

}> + +
+ ); +} + +async function Result({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return

{`prefetched ${id}`}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/open-next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/open-next.config.ts new file mode 100644 index 000000000000..fa6cb73ebf69 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/open-next.config.ts @@ -0,0 +1,3 @@ +import { defineCloudflareConfig } from '@opennextjs/cloudflare'; + +export default defineCloudflareConfig({}); 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 309cb063a0a5..4c25497fff11 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 @@ -20,9 +20,12 @@ "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:assert-webpack": "TEST_BUNDLER=webpack pnpm test:prod && pnpm test:dev-webpack" + "test:assert-webpack": "TEST_BUNDLER=webpack pnpm test:prod && pnpm test:dev-webpack", + "test:build:cloudflare": "pnpm install && opennextjs-cloudflare build", + "start:cloudflare": "opennextjs-cloudflare preview --var \"E2E_TEST_DSN:$E2E_TEST_DSN\"" }, "dependencies": { + "@opennextjs/cloudflare": "^1.20.10", "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "import-in-the-middle": "^1", @@ -40,7 +43,8 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^16", - "typescript": "^5" + "typescript": "^5", + "wrangler": "^4.148.0" }, "volta": { "extends": "../../package.json" @@ -52,6 +56,14 @@ "label": "nextjs-16-cacheComponents (webpack)", "assert-command": "pnpm test:assert-webpack" } + ], + "optionalVariants": [ + { + "runtime": "cloudflare", + "build-command": "pnpm test:build:cloudflare", + "assert-command": "pnpm test:prod", + "label": "nextjs-16-cacheComponents (cloudflare)" + } ] } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/playwright.config.mjs index 797418b8cf7d..43c2eb7e575e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/playwright.config.mjs @@ -1,10 +1,15 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { getPlaywrightConfig, getRuntime } from '@sentry-internal/test-utils'; const testEnv = process.env.TEST_ENV; if (!testEnv) { throw new Error('No test env defined'); } +const productionStartCommands = { + node: 'pnpm next start -p 3030', + cloudflare: 'pnpm start:cloudflare --port 3030', +}; + const getStartCommand = () => { if (testEnv === 'development-webpack') { return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs'; @@ -15,7 +20,7 @@ const getStartCommand = () => { } if (testEnv === 'production') { - return 'pnpm next start -p 3030'; + return productionStartCommands[getRuntime()]; } throw new Error(`Unknown test env: ${testEnv}`); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/prefetchOverlap.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/prefetchOverlap.spec.ts new file mode 100644 index 000000000000..e5666a4f909e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/prefetchOverlap.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; + +const REQUEST_COUNT = 12; + +// Next.js aborts a Cache Components prerender when randomness is generated while it runs. On Cloudflare Workers the +// SDK used to generate it inside the prerender of another request once prefetches overlapped, which turned their +// responses into the one byte partial marker `~` (https://github.com/getsentry/sentry-javascript/issues/25245). +test('overlapping runtime prefetch requests render their pages', async ({ request }) => { + // `Next-Router-Prefetch: 2` is what the Next.js router sends for a runtime prefetch, which Next.js answers with a + // prerender at request time. + const responses = await Promise.all( + Array.from({ length: REQUEST_COUNT }, (_, index) => + request.get(`/prefetch-overlap/overlap-${index + 1}`, { headers: { RSC: '1', 'Next-Router-Prefetch': '2' } }), + ), + ); + + for (const [index, response] of responses.entries()) { + expect(response.ok()).toBe(true); + expect(await response.text()).toContain(`prefetched overlap-${index + 1}`); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts new file mode 100644 index 000000000000..c70ed6462885 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/nextjs/cloudflare'; +// @ts-ignore the file exists only after `opennextjs-cloudflare build` +import handler from './.open-next/worker.js'; + +// @ts-ignore the file exists only after `opennextjs-cloudflare build` +export { DOQueueHandler, DOShardedTagCache, BucketCachePurge } from './.open-next/worker.js'; + +export default Sentry.withSentry( + (env: { E2E_TEST_DSN: string }) => ({ + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + }), + handler, +); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/wrangler.jsonc new file mode 100644 index 000000000000..ec500c50130d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/wrangler.jsonc @@ -0,0 +1,21 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "nextjs-16-cachecomponents", + "main": "./worker.ts", + // From 2025-06-16 on, workerd binds an `AsyncLocalStorage` snapshot to the request that took it, which + // tests/prefetchOverlap.spec.ts relies on. + "compatibility_date": "2026-02-19", + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "assets": { + "binding": "ASSETS", + "directory": ".open-next/assets", + }, + "services": [ + { + // Self-reference service binding, the service name must match the worker name + // see https://opennext.js.org/cloudflare/caching + "binding": "WORKER_SELF_REFERENCE", + "service": "nextjs-16-cachecomponents", + }, + ], +} diff --git a/packages/core/src/utils/randomSafeContext.ts b/packages/core/src/utils/randomSafeContext.ts index ce4bf5a8f16d..f79d8f9ec08b 100644 --- a/packages/core/src/utils/randomSafeContext.ts +++ b/packages/core/src/utils/randomSafeContext.ts @@ -2,27 +2,27 @@ import { GLOBAL_OBJ } from './worldwide'; export type RandomSafeContextRunner = (callback: () => T) => T; -// undefined = not yet resolved, null = no runner found, function = runner found -let RESOLVED_RUNNER: RandomSafeContextRunner | null | undefined; +let RESOLVED_RUNNER: RandomSafeContextRunner | undefined; /** * Simple wrapper that allows SDKs to *secretly* set context wrapper to generate safe random IDs in cache components contexts */ export function withRandomSafeContext(cb: () => T): T { - // Skips future symbol lookups if we've already resolved (or attempted to resolve) the runner once - if (RESOLVED_RUNNER !== undefined) { - return RESOLVED_RUNNER ? RESOLVED_RUNNER(cb) : cb(); + // Skips future symbol lookups once the runner is resolved. A missing runner is looked up again on every call, because + // an SDK can register it after the first random value was generated, e.g. when a Worker loads Next.js inside a request. + if (RESOLVED_RUNNER) { + return RESOLVED_RUNNER(cb); } const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ; + const runner = globalWithSymbol[sym]; - if (sym in globalWithSymbol && typeof globalWithSymbol[sym] === 'function') { - RESOLVED_RUNNER = globalWithSymbol[sym]; - return RESOLVED_RUNNER(cb); + if (typeof runner === 'function') { + RESOLVED_RUNNER = runner; + return runner(cb); } - RESOLVED_RUNNER = null; return cb(); } diff --git a/packages/core/test/lib/utils/randomSafeContext.test.ts b/packages/core/test/lib/utils/randomSafeContext.test.ts new file mode 100644 index 000000000000..acc1013d8eb0 --- /dev/null +++ b/packages/core/test/lib/utils/randomSafeContext.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const RUNNER_KEY = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); + +type Runner = (callback: () => T) => T; + +function registerRunner(runner: Runner): void { + (globalThis as Record)[RUNNER_KEY] = runner; +} + +describe('withRandomSafeContext', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + Reflect.deleteProperty(globalThis, RUNNER_KEY); + }); + + it('calls the callback directly when no runner is registered', async () => { + const { withRandomSafeContext } = await import('../../../src/utils/randomSafeContext'); + + expect(withRandomSafeContext(() => 'value')).toBe('value'); + }); + + it('uses a runner that is registered after the first call', async () => { + const { withRandomSafeContext } = await import('../../../src/utils/randomSafeContext'); + expect(withRandomSafeContext(() => 'first')).toBe('first'); + + const runner = vi.fn((callback: () => T): T => callback()); + registerRunner(runner); + + expect(withRandomSafeContext(() => 'second')).toBe('second'); + expect(runner).toHaveBeenCalledTimes(1); + }); + + it('keeps using the runner it resolved', async () => { + const { withRandomSafeContext } = await import('../../../src/utils/randomSafeContext'); + const runner = vi.fn((callback: () => T): T => callback()); + registerRunner(runner); + + withRandomSafeContext(() => 'first'); + Reflect.deleteProperty(globalThis, RUNNER_KEY); + withRandomSafeContext(() => 'second'); + + expect(runner).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts index b060ae040cfe..8fa0e6996dcb 100644 --- a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts +++ b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts @@ -5,6 +5,14 @@ import { DEBUG_BUILD } from '../common/debug-build'; // Avoids conflict with resolving it from getBuiltinModule type OriginalAsyncLocalStorage = typeof AsyncLocalStorage; +interface WorkUnitAsyncStorage { + exit(callback: () => T): T; +} + +// Next.js >= 16.4 anchors its async storages to `globalThis` under `Symbol.for('@next/@')` +// (`next/src/server/app-render/async-local-storage.ts`). +const NEXT_WORK_UNIT_ASYNC_STORAGE_KEY_PREFIX = '@next/work-unit-async-storage@'; + /** * Prepares the global object to generate safe random IDs in cache components contexts * See: https://github.com/getsentry/sentry-javascript/blob/ceb003c15973c2d8f437dfb7025eedffbc8bc8b0/packages/core/src/utils/propagationContext.ts#L1 @@ -13,9 +21,11 @@ export function prepareSafeIdGeneratorContext(): void { const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ; - // Get initial snapshot - if unavailable, don't set up the wrapper at all + let workUnitAsyncStorage = getNextWorkUnitAsyncStorage(); + + // Without a snapshot or the Next.js storage there is nothing to run the callback in, so don't set up the wrapper const initialSnapshot = getAsyncLocalStorageSnapshot(); - if (!initialSnapshot) { + if (!initialSnapshot && !workUnitAsyncStorage) { return; } @@ -23,9 +33,19 @@ export function prepareSafeIdGeneratorContext(): void { // environments (e.g., Cloudflare Workers), the snapshot is bound to the request // context it was created in. Once that request ends, the snapshot becomes invalid. // The wrapper catches this and creates a fresh snapshot for the current request context. - let cachedSnapshot: RandomSafeContextRunner = initialSnapshot; + let cachedSnapshot = initialSnapshot; globalWithSymbol[sym] = (callback: () => T): T => { + // Next.js only flags sync IO while its work unit store is active, so exiting that store is enough and works in + // any request. A snapshot is only safe when taken outside of a render and, on Cloudflare Workers, only in the + // request that took it (`bind_asynclocalstorage_snapshot_to_request`). + if (workUnitAsyncStorage) { + return workUnitAsyncStorage.exit(callback); + } + if (!cachedSnapshot) { + return callback(); + } + try { return cachedSnapshot(callback); } catch (error) { @@ -34,6 +54,11 @@ export function prepareSafeIdGeneratorContext(): void { throw error; } + workUnitAsyncStorage = getNextWorkUnitAsyncStorage(); + if (workUnitAsyncStorage) { + return workUnitAsyncStorage.exit(callback); + } + // Snapshot likely stale, try to get a fresh one and retry const freshSnapshot = getAsyncLocalStorageSnapshot(); // No snapshot available, fall back to direct execution @@ -61,6 +86,29 @@ export function prepareSafeIdGeneratorContext(): void { DEBUG_BUILD && debug.log('[@sentry/nextjs] Prepared safe random ID generator context'); } +function getNextWorkUnitAsyncStorage(): WorkUnitAsyncStorage | undefined { + const globalWithStorages = GLOBAL_OBJ as typeof GLOBAL_OBJ & Record; + + for (const key of Object.getOwnPropertySymbols(globalWithStorages)) { + if (!key.description?.startsWith(NEXT_WORK_UNIT_ASYNC_STORAGE_KEY_PREFIX)) { + continue; + } + const storage = globalWithStorages[key] as WorkUnitAsyncStorage | undefined; + if (typeof storage?.exit !== 'function') { + continue; + } + try { + // Where no AsyncLocalStorage exists, Next.js registers a `FakeAsyncLocalStorage` whose `exit` always throws + storage.exit(() => undefined); + return storage; + } catch { + // not usable + } + } + + return undefined; +} + function getAsyncLocalStorage(): OriginalAsyncLocalStorage | undefined { // May exist in the Next.js runtime globals // Doesn't exist in some of our tests diff --git a/packages/nextjs/test/server/prepareSafeIdGeneratorContext.test.ts b/packages/nextjs/test/server/prepareSafeIdGeneratorContext.test.ts new file mode 100644 index 000000000000..9fce481d75e8 --- /dev/null +++ b/packages/nextjs/test/server/prepareSafeIdGeneratorContext.test.ts @@ -0,0 +1,147 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { prepareSafeIdGeneratorContext } from '../../src/server/prepareSafeIdGeneratorContext'; + +const RUNNER_KEY = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); +const NEXT_WORK_UNIT_STORAGE_KEY = Symbol.for('@next/work-unit-async-storage@16.4.0'); + +type Runner = (callback: () => T) => T; + +function getRunner(): Runner { + const runner = (globalThis as Record)[RUNNER_KEY]; + if (typeof runner !== 'function') { + throw new Error('runner not registered'); + } + return runner as Runner; +} + +function registerNextWorkUnitStorage(storage: AsyncLocalStorage): void { + (globalThis as Record)[NEXT_WORK_UNIT_STORAGE_KEY] = storage; +} + +/** + * Mimics workerd with `bind_asynclocalstorage_snapshot_to_request`: a snapshot taken during one request throws when + * it is called during another one. + */ +function bindSnapshotsToRequest(): { enterRequest: (id: number) => void } { + const originalSnapshot = AsyncLocalStorage.snapshot.bind(AsyncLocalStorage); + let currentRequest = 0; + + vi.spyOn(AsyncLocalStorage, 'snapshot').mockImplementation(() => { + const boundRequest = currentRequest; + const snapshot = originalSnapshot(); + return ((callback: () => unknown) => { + if (currentRequest !== boundRequest) { + throw new Error( + 'Cannot call this AsyncLocalStorage bound function outside of the request in which it was created.', + ); + } + return snapshot(callback); + }) as ReturnType; + }); + + return { + enterRequest: id => { + currentRequest = id; + }, + }; +} + +describe('prepareSafeIdGeneratorContext', () => { + let workUnitStorage: AsyncLocalStorage<{ type: string }>; + const prerenderStore = { type: 'prerender' }; + + beforeEach(() => { + workUnitStorage = new AsyncLocalStorage(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Reflect.deleteProperty(globalThis, RUNNER_KEY); + Reflect.deleteProperty(globalThis, NEXT_WORK_UNIT_STORAGE_KEY); + }); + + it('runs the callback outside of the Next.js work unit store using a snapshot', () => { + prepareSafeIdGeneratorContext(); + + const storeSeen = workUnitStorage.run(prerenderStore, () => getRunner()(() => workUnitStorage.getStore())); + + expect(storeSeen).toBeUndefined(); + }); + + it('exits the work unit storage that Next.js registers on globalThis', () => { + registerNextWorkUnitStorage(workUnitStorage); + prepareSafeIdGeneratorContext(); + + const storeSeen = workUnitStorage.run(prerenderStore, () => getRunner()(() => workUnitStorage.getStore())); + + expect(storeSeen).toBeUndefined(); + }); + + it('keeps unrelated stores intact when exiting the work unit storage', () => { + registerNextWorkUnitStorage(workUnitStorage); + prepareSafeIdGeneratorContext(); + const otherStorage = new AsyncLocalStorage(); + + const storeSeen = otherStorage.run('request-data', () => + workUnitStorage.run(prerenderStore, () => getRunner()(() => otherStorage.getStore())), + ); + + expect(storeSeen).toBe('request-data'); + }); + + it('does not run the callback inside a prerender of another request when snapshots are request-bound', () => { + const { enterRequest } = bindSnapshotsToRequest(); + registerNextWorkUnitStorage(workUnitStorage); + + enterRequest(1); + prepareSafeIdGeneratorContext(); + const runner = getRunner(); + + enterRequest(2); + const storeSeen = workUnitStorage.run(prerenderStore, () => runner(() => workUnitStorage.getStore())); + + expect(storeSeen).toBeUndefined(); + }); + + it('finds the work unit storage that Next.js registers after the runner was prepared', () => { + const { enterRequest } = bindSnapshotsToRequest(); + + enterRequest(1); + prepareSafeIdGeneratorContext(); + const runner = getRunner(); + registerNextWorkUnitStorage(workUnitStorage); + + enterRequest(2); + const storeSeen = workUnitStorage.run(prerenderStore, () => runner(() => workUnitStorage.getStore())); + + expect(storeSeen).toBeUndefined(); + }); + + it('ignores a registered storage whose exit throws, like the Next.js FakeAsyncLocalStorage', () => { + const fakeStorage = { + getStore: () => undefined, + exit: () => { + throw new Error('Invariant: AsyncLocalStorage accessed in runtime where it is not available'); + }, + }; + (globalThis as Record)[NEXT_WORK_UNIT_STORAGE_KEY] = fakeStorage; + prepareSafeIdGeneratorContext(); + + const storeSeen = workUnitStorage.run(prerenderStore, () => getRunner()(() => workUnitStorage.getStore())); + + expect(storeSeen).toBeUndefined(); + }); + + it('falls back to a fresh snapshot when Next.js does not register its storage', () => { + const { enterRequest } = bindSnapshotsToRequest(); + + enterRequest(1); + prepareSafeIdGeneratorContext(); + const runner = getRunner(); + + enterRequest(2); + expect(runner(() => 'value')).toBe('value'); + expect(AsyncLocalStorage.snapshot).toHaveBeenCalledTimes(2); + }); +});