diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.mjs new file mode 100644 index 000000000000..5086e5695292 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.mjs @@ -0,0 +1,12 @@ +// Deliberately `.mjs`: Next loads it through Node's own ESM loader rather than compiling it, which is the only +// config format that exercises `@sentry/nextjs/config` as a plain-Node ESM consumer. +import { withSentryConfig } from '@sentry/nextjs/config'; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + trailingSlash: true, +}; + +export default withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts deleted file mode 100644 index 80946b61ec01..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { withSentryConfig } from '@sentry/nextjs'; -import type { NextConfig } from 'next'; - -const nextConfig: NextConfig = { - trailingSlash: true, -}; - -export default withSentryConfig(nextConfig, { - silent: true, -}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts index cfdfe12d0c27..7bcb11c8c7af 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; // These tests verify that pageload transactions are correctly named when -// trailingSlash: true is enabled in next.config.ts, even when a catch-all +// trailingSlash: true is enabled in next.config.mjs, even when a catch-all // route exists. See: https://github.com/getsentry/sentry-javascript/issues/19241 test('should create a correctly named pageload transaction for a static route', async ({ page }) => { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index a5f33d7c2e22..98ad42b3bdd3 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -46,6 +46,10 @@ "node": "./build/cjs/index.server.js", "import": "./build/esm/index.server.js" }, + "./config": { + "types": "./build/types/config/index.d.ts", + "default": "./build/cjs/config/index.js" + }, "./async-storage-shim": { "import": { "default": "./build/esm/config/templates/requestAsyncStorageShim.js" diff --git a/packages/nextjs/src/config/deprecatedWithSentryConfig.ts b/packages/nextjs/src/config/deprecatedWithSentryConfig.ts new file mode 100644 index 000000000000..f80f3b0f6cc4 --- /dev/null +++ b/packages/nextjs/src/config/deprecatedWithSentryConfig.ts @@ -0,0 +1,24 @@ +import { consoleSandbox } from '@sentry/core'; +import type { SentryBuildOptions } from './types'; +import { withSentryConfig as withSentryConfigImpl } from './withSentryConfig'; + +let hasWarned = false; + +/** + * Deprecation shim for the `withSentryConfig` re-export on the `@sentry/nextjs` entry. Kept separate from + * `./config` so that importing from `@sentry/nextjs/config` stays silent. + */ +export function withSentryConfig(nextConfig?: C, sentryBuildOptions: SentryBuildOptions = {}): C { + if (!hasWarned) { + hasWarned = true; + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] Importing `withSentryConfig` from `@sentry/nextjs` is deprecated and will stop working in v11. Import it from `@sentry/nextjs/config` instead:\n' + + " import { withSentryConfig } from '@sentry/nextjs/config';", + ); + }); + } + + return withSentryConfigImpl(nextConfig, sentryBuildOptions); +} diff --git a/packages/nextjs/src/index.server.ts b/packages/nextjs/src/index.server.ts index 133b6ecf1da0..c868e5c6571c 100644 --- a/packages/nextjs/src/index.server.ts +++ b/packages/nextjs/src/index.server.ts @@ -1,2 +1,3 @@ -export * from './config'; +export { withSentryConfig } from './config/deprecatedWithSentryConfig'; +export type { SentryBuildOptions } from './config'; export * from './server'; diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index e0d918708bb1..6313f39e4a19 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -8,10 +8,11 @@ import type { Client, Integration, Options, StackParser } from '@sentry/core'; import type * as clientSdk from './client'; import type { ServerComponentContext, VercelCronsConfig } from './common/types'; +import type * as configSdk from './config'; import type * as edgeSdk from './edge'; import type * as serverSdk from './server'; -export * from './config'; +export type { SentryBuildOptions } from './config'; export * from './client'; export * from './server'; export * from './edge'; @@ -44,7 +45,11 @@ export declare const withErrorBoundary: typeof clientSdk.withErrorBoundary; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; -export { withSentryConfig } from './config'; +/** + * @deprecated Import `withSentryConfig` from `@sentry/nextjs/config` instead. The `@sentry/nextjs` export is removed + * in v11. + */ +export declare const withSentryConfig: typeof configSdk.withSentryConfig; /** * Wraps a Next.js Pages Router API route with Sentry error and performance instrumentation. diff --git a/packages/nextjs/test/configExports.test.ts b/packages/nextjs/test/configExports.test.ts new file mode 100644 index 000000000000..930247fc74db --- /dev/null +++ b/packages/nextjs/test/configExports.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { init, parse } from 'cjs-module-lexer'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/config/withSentryConfig', () => ({ + withSentryConfig: vi.fn((nextConfig: unknown) => nextConfig), +})); + +/** + * `next.config.mjs` is loaded by a plain Node ESM loader, but build-time config code resolves webpack loader and + * template paths with `__dirname`, which is a `ReferenceError` in an ES module. So `./config` deliberately serves the + * CJS build to ESM importers too, rather than splitting `import`/`require` like the runtime entries do. + * + * There is no dual-package hazard here because this code runs at build time only and holds no SDK state. + */ +describe('`./config` subpath export', () => { + const packageExports = ( + JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8')) as { + exports: Record; + } + ).exports; + + it('resolves to the CJS build for every condition', () => { + expect(packageExports['./config']).toEqual({ + types: './build/types/config/index.d.ts', + default: './build/cjs/config/index.js', + }); + }); + + it('never points a condition at the ESM config build', () => { + expect(JSON.stringify(packageExports['./config'])).not.toContain('build/esm'); + }); +}); + +/** + * ESM consumers of a CJS file only get the named exports `cjs-module-lexer` can see statically — anything it misses + * links as `undefined`. So `withSentryConfig` has to stay statically detectable for `import { withSentryConfig } from + * '@sentry/nextjs/config'` to work in a `next.config.mjs`. + * + * Exercises the generated artifact, so it needs the package built. + */ +describe('`./config` static exports (generated)', () => { + let staticExports: string[]; + + beforeAll(async () => { + await init(); + staticExports = parse(readFileSync(resolve(__dirname, '../build/cjs/config/index.js'), 'utf8')).exports; + }); + + it('statically exports `withSentryConfig`', () => { + expect(staticExports).toContain('withSentryConfig'); + }); +}); + +describe('deprecated `withSentryConfig` on the `@sentry/nextjs` entry', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('delegates to `@sentry/nextjs/config`', async () => { + const { withSentryConfig } = await import('../src/config/deprecatedWithSentryConfig'); + const { withSentryConfig: withSentryConfigImpl } = await import('../src/config/withSentryConfig'); + const nextConfig = { reactStrictMode: true }; + + expect(withSentryConfig(nextConfig, { silent: true })).toBe(nextConfig); + expect(withSentryConfigImpl).toHaveBeenCalledWith(nextConfig, { silent: true }); + }); + + it('warns once, no matter how often the config is materialized', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { withSentryConfig } = await import('../src/config/deprecatedWithSentryConfig'); + + withSentryConfig({}); + withSentryConfig({}); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("import { withSentryConfig } from '@sentry/nextjs/config'"), + ); + }); + + it('does not warn when imported from `@sentry/nextjs/config`', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { withSentryConfig } = await import('../src/config'); + + withSentryConfig({}); + + expect(warn).not.toHaveBeenCalled(); + }); +});