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);
+ });
+});