From 08d6db4293b2bcdf9933c9a7b6b93bc3b63d8d63 Mon Sep 17 00:00:00 2001
From: Charly Gomez
Date: Tue, 13 Oct 2026 10:50:24 +0200
Subject: [PATCH 1/5] fix(nextjs): Don't abort Cache Components renders on
Cloudflare Workers when requests overlap
Co-Authored-By: Claude Fable 5.1
---
.../nextjs-16-cacheComponents/.gitignore | 3 +
.../app/prefetch-overlap/[id]/page.tsx | 14 ++
.../open-next.config.ts | 3 +
.../nextjs-16-cacheComponents/package.json | 16 +-
.../playwright.config.mjs | 9 +-
.../tests/prefetchOverlap.spec.ts | 21 +++
.../nextjs-16-cacheComponents/worker.ts | 17 ++
.../nextjs-16-cacheComponents/wrangler.jsonc | 21 +++
packages/core/src/utils/randomSafeContext.ts | 18 +--
.../test/lib/utils/randomSafeContext.test.ts | 48 ++++++
.../server/prepareSafeIdGeneratorContext.ts | 54 ++++++-
.../prepareSafeIdGeneratorContext.test.ts | 147 ++++++++++++++++++
12 files changed, 355 insertions(+), 16 deletions(-)
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/prefetch-overlap/[id]/page.tsx
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/open-next.config.ts
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/prefetchOverlap.spec.ts
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/wrangler.jsonc
create mode 100644 packages/core/test/lib/utils/randomSafeContext.test.ts
create mode 100644 packages/nextjs/test/server/prepareSafeIdGeneratorContext.test.ts
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 2534a55b6b69..874486187996 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",
@@ -41,7 +44,8 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "^16",
- "typescript": "^5"
+ "typescript": "^5",
+ "wrangler": "^4.148.0"
},
"sentryTest": {
"runner": "namespace-profile-sentry-javascript",
@@ -51,6 +55,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 7bef06fe211b..08346e4de857 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);
+ });
+});
From 6dcfd5b2c8689ad3088397e6a1feac8eed2e9bec Mon Sep 17 00:00:00 2001
From: Charly Gomez
Date: Wed, 28 Oct 2026 12:36:47 +0100
Subject: [PATCH 2/5] test(nextjs): Allow the esbuild and workerd build scripts
in the Cache Components E2E app
Co-Authored-By: Claude Fable 5.1
---
.../nextjs-16-cacheComponents/pnpm-workspace.yaml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/pnpm-workspace.yaml
index bde9dfb7b138..0aaed65978c9 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/pnpm-workspace.yaml
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
allowBuilds:
+ esbuild: true
unrs-resolver: true
+ workerd: true
minimumReleaseAge: 0
From 8b0441f593bb0df9001681e436628c93eb82c0a0 Mon Sep 17 00:00:00 2001
From: Charly Gomez
Date: Tue, 3 Nov 2026 13:59:54 +0100
Subject: [PATCH 3/5] ref(core): Resolve the safe random ID runner symbol once
Co-Authored-By: Claude Fable 5.1
---
.../nextjs-16-cacheComponents/package.json | 2 +-
packages/core/src/utils/randomSafeContext.ts | 7 ++++---
2 files changed, 5 insertions(+), 4 deletions(-)
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 874486187996..a63a37ac04f0 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
@@ -25,7 +25,7 @@
"start:cloudflare": "opennextjs-cloudflare preview --var \"E2E_TEST_DSN:$E2E_TEST_DSN\""
},
"dependencies": {
- "@opennextjs/cloudflare": "^1.20.10",
+ "@opennextjs/cloudflare": "^1.20.12",
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
"import-in-the-middle": "^1",
diff --git a/packages/core/src/utils/randomSafeContext.ts b/packages/core/src/utils/randomSafeContext.ts
index f79d8f9ec08b..33e0988ba673 100644
--- a/packages/core/src/utils/randomSafeContext.ts
+++ b/packages/core/src/utils/randomSafeContext.ts
@@ -2,6 +2,8 @@ import { GLOBAL_OBJ } from './worldwide';
export type RandomSafeContextRunner = (callback: () => T) => T;
+const RUNNER_KEY = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__');
+
let RESOLVED_RUNNER: RandomSafeContextRunner | undefined;
/**
@@ -14,9 +16,8 @@ export function withRandomSafeContext(cb: () => T): T {
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];
+ const globalWithSymbol: typeof GLOBAL_OBJ & { [RUNNER_KEY]?: RandomSafeContextRunner } = GLOBAL_OBJ;
+ const runner = globalWithSymbol[RUNNER_KEY];
if (typeof runner === 'function') {
RESOLVED_RUNNER = runner;
From 78c8138b97ceba24bea891d1251916283f76b314 Mon Sep 17 00:00:00 2001
From: Charly Gomez
Date: Wed, 4 Nov 2026 15:44:23 +0100
Subject: [PATCH 4/5] ref(nextjs): Register the safe random ID runner from the
Cloudflare entry
Registers the runner at module scope of `@sentry/nextjs/cloudflare`, which runs at isolate startup before the
Worker's copy of `@sentry/core` generates its first ID. This restores the one-time runner resolution in core, so
SDKs without a runner don't look it up on every random value. Also leaves `traceLifecycle` unset in the Cache
Components Worker.
Co-Authored-By: Claude Fable 5.1
---
.../nextjs-16-cacheComponents/worker.ts | 1 -
packages/core/src/utils/randomSafeContext.ts | 23 +++++----
.../test/lib/utils/randomSafeContext.test.ts | 48 -------------------
packages/nextjs/src/cloudflare/index.ts | 5 ++
.../prepareSafeIdGeneratorContext.test.ts | 12 +++++
5 files changed, 28 insertions(+), 61 deletions(-)
delete mode 100644 packages/core/test/lib/utils/randomSafeContext.test.ts
create mode 100644 packages/nextjs/test/cloudflare/prepareSafeIdGeneratorContext.test.ts
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
index c70ed6462885..54e00a0afba2 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts
@@ -7,7 +7,6 @@ export { DOQueueHandler, DOShardedTagCache, BucketCachePurge } from './.open-nex
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
diff --git a/packages/core/src/utils/randomSafeContext.ts b/packages/core/src/utils/randomSafeContext.ts
index 33e0988ba673..ce4bf5a8f16d 100644
--- a/packages/core/src/utils/randomSafeContext.ts
+++ b/packages/core/src/utils/randomSafeContext.ts
@@ -2,28 +2,27 @@ import { GLOBAL_OBJ } from './worldwide';
export type RandomSafeContextRunner = (callback: () => T) => T;
-const RUNNER_KEY = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__');
-
-let RESOLVED_RUNNER: RandomSafeContextRunner | undefined;
+// undefined = not yet resolved, null = no runner found, function = runner found
+let RESOLVED_RUNNER: RandomSafeContextRunner | null | 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 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);
+ // 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();
}
- const globalWithSymbol: typeof GLOBAL_OBJ & { [RUNNER_KEY]?: RandomSafeContextRunner } = GLOBAL_OBJ;
- const runner = globalWithSymbol[RUNNER_KEY];
+ const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__');
+ const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ;
- if (typeof runner === 'function') {
- RESOLVED_RUNNER = runner;
- return runner(cb);
+ if (sym in globalWithSymbol && typeof globalWithSymbol[sym] === 'function') {
+ RESOLVED_RUNNER = globalWithSymbol[sym];
+ return RESOLVED_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
deleted file mode 100644
index acc1013d8eb0..000000000000
--- a/packages/core/test/lib/utils/randomSafeContext.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-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/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts
index 9c43cff4b5a3..5c8b65007cd4 100644
--- a/packages/nextjs/src/cloudflare/index.ts
+++ b/packages/nextjs/src/cloudflare/index.ts
@@ -11,12 +11,17 @@ import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'
import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors';
import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd';
import { NextSentryPropagator } from '../server/nextSentryPropagator';
+import { prepareSafeIdGeneratorContext } from '../server/prepareSafeIdGeneratorContext';
import { getGlobalTracerProvider, markPrerendersOnTracerProvider } from '../server/prerenderContext';
import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from '../server/serverSpanHooks';
import { nextjsUseCacheIntegration } from '../server/useCacheInstrumentation';
export * from '@sentry/cloudflare';
+// At isolate startup, so the copy of `@sentry/core` in the Worker bundle finds the runner on its first ID. A snapshot
+// taken here is not bound to a request, unlike one taken while OpenNext loads Next.js inside the first request.
+prepareSafeIdGeneratorContext();
+
class NextjsCloudflarePropagator extends NextSentryPropagator {
/** @inheritDoc */
public extract(ctx: Context, carrier: unknown, getter: TextMapGetter): Context {
diff --git a/packages/nextjs/test/cloudflare/prepareSafeIdGeneratorContext.test.ts b/packages/nextjs/test/cloudflare/prepareSafeIdGeneratorContext.test.ts
new file mode 100644
index 000000000000..9838296d6df6
--- /dev/null
+++ b/packages/nextjs/test/cloudflare/prepareSafeIdGeneratorContext.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest';
+import '../../src/cloudflare';
+
+const RUNNER_KEY = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__');
+
+describe('@sentry/nextjs/cloudflare', () => {
+ it('registers the safe random ID runner when the module loads', () => {
+ const runner = (globalThis as Record)[RUNNER_KEY] as ((callback: () => T) => T) | undefined;
+ expect(runner).toBeTypeOf('function');
+ expect(runner!(() => 'ok')).toBe('ok');
+ });
+});
From 8c0fb1a23131d9d2eb072e7f02fba0fc379a8c1f Mon Sep 17 00:00:00 2001
From: Charly Gomez
Date: Wed, 4 Nov 2026 16:36:39 +0100
Subject: [PATCH 5/5] test(nextjs): Keep the static trace lifecycle in the
Cache Components Worker
On Workers the request transaction belongs to the `withSentry` client. The specs of this app wait for transaction
envelopes, so with span streaming in the Worker they time out even when the pages render (verified against the
OpenNext preview of opennextjs/opennextjs-cloudflare#1318).
Co-Authored-By: Claude Fable 5.1
---
.../test-applications/nextjs-16-cacheComponents/worker.ts | 3 +++
1 file changed, 3 insertions(+)
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
index 54e00a0afba2..44cb2e195eea 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts
@@ -7,6 +7,9 @@ export { DOQueueHandler, DOShardedTagCache, BucketCachePurge } from './.open-nex
export default Sentry.withSentry(
(env: { E2E_TEST_DSN: string }) => ({
+ // The specs of this app assert transaction envelopes, like `sentry.server.config.ts`. On Workers the request
+ // transaction belongs to this client, so with span streaming here they would never see one.
+ traceLifecycle: 'static',
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: env.E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server