Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ next-env.d.ts

# Sentry Config File
.env.sentry-build-plugin

# OpenNext build output
/.open-next/
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Suspense } from 'react';

export default function Page({ params }: { params: Promise<{ id: string }> }) {
return (
<Suspense fallback={<p>Loading...</p>}>
<Result params={params} />
</Suspense>
);
}

async function Result({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <p id="result">{`prefetched ${id}`}</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare';

export default defineCloudflareConfig({});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand All @@ -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)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -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}`);
}
});
Original file line number Diff line number Diff line change
@@ -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,
);
Original file line number Diff line number Diff line change
@@ -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",
},
],
}
18 changes: 9 additions & 9 deletions packages/core/src/utils/randomSafeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,27 @@ import { GLOBAL_OBJ } from './worldwide';

export type RandomSafeContextRunner = <T>(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<T>(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();
}

Expand Down
48 changes: 48 additions & 0 deletions packages/core/test/lib/utils/randomSafeContext.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(callback: () => T) => T;

function registerRunner(runner: Runner): void {
(globalThis as Record<symbol, unknown>)[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(<T>(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(<T>(callback: () => T): T => callback());
registerRunner(runner);

withRandomSafeContext(() => 'first');
Reflect.deleteProperty(globalThis, RUNNER_KEY);
withRandomSafeContext(() => 'second');

expect(runner).toHaveBeenCalledTimes(2);
});
});
54 changes: 51 additions & 3 deletions packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(callback: () => T): T;
}

// Next.js >= 16.4 anchors its async storages to `globalThis` under `Symbol.for('@next/<name>@<version>')`
// (`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
Expand All @@ -13,19 +21,31 @@ 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;
}

// We store a wrapper function instead of the raw snapshot because in serverless
// 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] = <T>(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) {
Expand All @@ -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
Expand Down Expand Up @@ -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<symbol, unknown>;

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
Expand Down
Loading
Loading