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 (
+
{`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..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 @@ -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.12", "@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/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 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..44cb2e195eea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/worker.ts @@ -0,0 +1,19 @@ +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 }) => ({ + // 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 + 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/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/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