diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/globals.css b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/globals.css
new file mode 100644
index 000000000000..9d23ceb08862
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/globals.css
@@ -0,0 +1,3 @@
+body {
+ font-family: sans-serif;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/layout.tsx
index 006a01fcfa76..98b208b6a110 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/layout.tsx
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/layout.tsx
@@ -1,3 +1,4 @@
+import './globals.css';
import Link from 'next/link';
import { SpanContextProvider } from '../components/span-context';
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/assert-build.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/assert-build.ts
index 903ebdb3a358..a8621b6a5aa1 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/assert-build.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/assert-build.ts
@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as assert from 'assert/strict';
-import { findSourceMapFiles } from '@sentry-internal/test-utils';
+import { findSourceMapFiles, findSourceMappingUrlComments } from '@sentry-internal/test-utils';
const nextPackageJson = require('next/package.json');
const nextjsVersion = nextPackageJson.version;
@@ -47,4 +47,12 @@ const getLatestNextVersion = async () => {
[],
'Client bundle .map files found even though `sourcemaps.deleteSourcemapsAfterUpload` option is set!',
);
+ assert.deepEqual(
+ findSourceMappingUrlComments({
+ outputDir: path.join('.next', 'static'),
+ extensions: ['.js', '.mjs', '.cjs', '.css'],
+ }),
+ [],
+ 'Client bundles reference source maps even though `sourcemaps.deleteSourcemapsAfterUpload` option is set!',
+ );
})();
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/assert-build.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/assert-build.ts
index 1ef943a854bb..56f191e3aa31 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/assert-build.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/assert-build.ts
@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as assert from 'assert/strict';
-import { findSourceMapFiles } from '@sentry-internal/test-utils';
+import { findSourceMapFiles, findSourceMappingUrlComments } from '@sentry-internal/test-utils';
const nextPackageJson = require('next/package.json');
const nextjsVersion = nextPackageJson.version;
@@ -39,4 +39,12 @@ const getLatestNextVersion = async () => {
[],
'Client bundle .map files found even though `sourcemaps.deleteSourcemapsAfterUpload` option is set!',
);
+ assert.deepEqual(
+ findSourceMappingUrlComments({
+ outputDir: path.join('.next', 'static'),
+ extensions: ['.js', '.mjs', '.cjs', '.css'],
+ }),
+ [],
+ 'Client bundles reference source maps even though `sourcemaps.deleteSourcemapsAfterUpload` option is set!',
+ );
})();
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/components/page.module.css b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/components/page.module.css
new file mode 100644
index 000000000000..83171499de84
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/components/page.module.css
@@ -0,0 +1,3 @@
+.page {
+ font-family: sans-serif;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/index.tsx b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/index.tsx
index 9dd0cc0aaa96..8803346e372b 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/index.tsx
+++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/index.tsx
@@ -1,9 +1,10 @@
import Link from 'next/link';
import { ClientErrorDebugTools } from '../components/client-error-debug-tools';
+import styles from '../components/page.module.css';
export default function Page() {
return (
-
+
Page (/)
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/globals.css b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/globals.css
new file mode 100644
index 000000000000..9d23ceb08862
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/globals.css
@@ -0,0 +1,3 @@
+body {
+ font-family: sans-serif;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/layout.tsx
index c8f9cee0b787..af7f72bf7e30 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/layout.tsx
+++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/layout.tsx
@@ -1,3 +1,4 @@
+import './globals.css';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/assert-build.ts
index 1f02a4bc61a2..049c8d57505d 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/assert-build.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/assert-build.ts
@@ -90,7 +90,7 @@ if (process.env.E2E_DELETE_SOURCEMAPS) {
const staticDir = '.next/static';
assert.deepEqual(findSourceMapFiles({ outputDir: staticDir }), [], `Expected no source maps in ${staticDir}`);
assert.deepEqual(
- findSourceMappingUrlComments({ outputDir: staticDir }),
+ findSourceMappingUrlComments({ outputDir: staticDir, extensions: ['.js', '.mjs', '.cjs', '.css'] }),
[],
`Expected no sourceMappingURL comments in ${staticDir}`,
);
diff --git a/packages/nextjs/src/config/getBuildPluginOptions.ts b/packages/nextjs/src/config/getBuildPluginOptions.ts
index fb3defba4c50..089305ffb704 100644
--- a/packages/nextjs/src/config/getBuildPluginOptions.ts
+++ b/packages/nextjs/src/config/getBuildPluginOptions.ts
@@ -1,4 +1,4 @@
-import type { Options as SentryBuildPluginOptions } from '@sentry/bundler-plugins/core';
+import type { InternalOptions as SentryBuildPluginOptions } from '@sentry/bundler-plugins/core';
import * as fs from 'fs';
import * as path from 'path';
import { getBuildLogger } from './buildLogger';
@@ -179,30 +179,39 @@ function createSourcemapUploadIgnorePattern(
}
/**
- * Creates file patterns for deletion after source map upload
+ * Creates file patterns for deletion after the source map upload that runs after the production compile.
+ * The webpack plugin removes the maps of webpack builds itself (see `deleteSourcemapsAfterUpload`).
*/
function createFilesToDeleteAfterUploadPattern(
normalizedDistPath: string,
buildTool: BuildTool,
deleteSourcemapsAfterUpload: boolean,
- useRunAfterProductionCompileHook: boolean = false,
): string[] | undefined {
- if (!deleteSourcemapsAfterUpload) {
+ if (!deleteSourcemapsAfterUpload || !buildTool.startsWith('after-production-compile')) {
return undefined;
}
+ return SOURCEMAP_EXTENSIONS.map(ext => path.posix.join(normalizedDistPath, 'static', '**', ext));
+}
+
+/**
+ * Whether the webpack plugin removes the source maps of this compiler from the build output.
+ */
+function shouldWebpackPluginDeleteSourcemaps(
+ buildTool: BuildTool,
+ deleteSourcemapsAfterUpload: boolean,
+ userFilesToDeleteAfterUpload: string | string[] | undefined,
+ useRunAfterProductionCompileHook: boolean = false,
+): boolean {
// We don't want to delete source maps for server builds as this led to errors on Vercel in the past
// See: https://github.com/getsentry/sentry-javascript/issues/13099
- if (buildTool === 'webpack-nodejs' || buildTool === 'webpack-edge') {
- return undefined;
- }
-
- // Skip deletion for webpack client builds when using the experimental hook
- if (buildTool === 'webpack-client' && useRunAfterProductionCompileHook) {
- return undefined;
- }
-
- return SOURCEMAP_EXTENSIONS.map(ext => path.posix.join(normalizedDistPath, 'static', '**', ext));
+ // With the hook, the upload runs after the compile and must still find the maps.
+ return (
+ buildTool === 'webpack-client' &&
+ !useRunAfterProductionCompileHook &&
+ userFilesToDeleteAfterUpload === undefined &&
+ deleteSourcemapsAfterUpload
+ );
}
/**
@@ -313,15 +322,19 @@ export function getBuildPluginOptions({
? Array.isArray(userFilesToDeleteAfterUpload)
? userFilesToDeleteAfterUpload
: [userFilesToDeleteAfterUpload]
- : createFilesToDeleteAfterUploadPattern(
- normalizedDistDirAbsPath,
- buildTool,
- deleteSourcemapsAfterUpload,
- useRunAfterProductionCompileHook,
- );
+ : createFilesToDeleteAfterUploadPattern(normalizedDistDirAbsPath, buildTool, deleteSourcemapsAfterUpload);
const skipSourcemapsUpload = shouldSkipSourcemapUpload(buildTool, useRunAfterProductionCompileHook);
+ const webpackPluginDeletesSourcemaps = buildTool.startsWith('webpack')
+ ? shouldWebpackPluginDeleteSourcemaps(
+ buildTool,
+ deleteSourcemapsAfterUpload,
+ userFilesToDeleteAfterUpload,
+ useRunAfterProductionCompileHook,
+ )
+ : undefined;
+
return {
// Only the browser reads these tags (`thirdPartyErrorFilterIntegration`), so server bundles skip the per-module cost.
applicationKey:
@@ -348,6 +361,10 @@ export function getBuildPluginOptions({
assets: sentryBuildOptions.sourcemaps?.assets ?? sourcemapUploadAssets,
ignore: finalIgnorePatterns,
filesToDeleteAfterUpload,
+ deleteSourcemapsAfterUpload: webpackPluginDeletesSourcemaps,
+ // Every client map goes, also those the user turned on with `productionBrowserSourceMaps` or
+ // `devtool`, and those of chunks the upload leaves out.
+ sdkEnabledSourceMaps: webpackPluginDeletesSourcemaps,
resolveSourceMap: sentryBuildOptions.sourcemaps?.resolveSourceMap,
},
release: createReleaseConfig(releaseName, sentryBuildOptions),
diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts
index b8957d083c4b..20b5e3067fbd 100644
--- a/packages/nextjs/src/config/types.ts
+++ b/packages/nextjs/src/config/types.ts
@@ -462,16 +462,15 @@ type SentryBuildSourceMapsOptions = Omit<
filesToDeleteAfterUpload?: string | string[];
/**
- * Toggle whether generated source maps within your Next.js build folder should be automatically deleted after being
- * uploaded to Sentry.
+ * Toggle whether the client source maps within your Next.js build folder should be automatically deleted after
+ * being uploaded to Sentry. Server source maps are kept.
*
- * Only applies to source maps the SDK generated itself. Setting `disable` to `true` or `"disable-upload"` leaves
- * source map generation entirely to your Next.js config, so nothing is auto-deleted either.
+ * When `true`, all client source maps are deleted, also those you turned on yourself (for example with
+ * `productionBrowserSourceMaps`) and those that were not uploaded.
*
- * Defaults to `true`.
+ * Defaults to `true` for webpack builds and for Turbopack builds in which the SDK turns on the client source maps.
+ * With `disable` set to `true` or `"disable-upload"`, nothing is deleted unless you set this option to `true`.
*/
- // TODO(v12): Collapse into `filesToDeleteAfterUpload`, which already overrides this and is part of the shared
- // build-time options. Two ways to express the same thing is one too many for a public API.
deleteSourcemapsAfterUpload?: boolean;
};
diff --git a/packages/nextjs/test/config/getBuildPluginOptions.test.ts b/packages/nextjs/test/config/getBuildPluginOptions.test.ts
index e04c76e5cb98..160517018abc 100644
--- a/packages/nextjs/test/config/getBuildPluginOptions.test.ts
+++ b/packages/nextjs/test/config/getBuildPluginOptions.test.ts
@@ -483,93 +483,121 @@ describe('getBuildPluginOptions', () => {
]);
});
- it('configures file deletion when deleteSourcemapsAfterUpload is enabled for webpack-client without useRunAfterProductionCompileHook', () => {
- const sentryBuildOptions: SentryBuildOptions = {
- org: 'test-org',
- project: 'test-project',
- sourcemaps: {
- deleteSourcemapsAfterUpload: true,
- },
- };
-
+ it('has the webpack plugin delete the client source maps instead of a glob', () => {
const result = getBuildPluginOptions({
- sentryBuildOptions,
+ sentryBuildOptions: {
+ org: 'test-org',
+ project: 'test-project',
+ sourcemaps: { deleteSourcemapsAfterUpload: true },
+ },
releaseName: mockReleaseName,
distDirAbsPath: mockDistDirAbsPath,
buildTool: 'webpack-client',
useRunAfterProductionCompileHook: false,
});
- expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([
- '/path/to/.next/static/**/*.js.map',
- '/path/to/.next/static/**/*.mjs.map',
- '/path/to/.next/static/**/*.cjs.map',
- '/path/to/.next/static/**/*.css.map',
- ]);
+ expect(result.sourcemaps).toMatchObject({
+ filesToDeleteAfterUpload: undefined,
+ deleteSourcemapsAfterUpload: true,
+ });
});
- it('does not configure file deletion when deleteSourcemapsAfterUpload is enabled for webpack-client with useRunAfterProductionCompileHook', () => {
- const sentryBuildOptions: SentryBuildOptions = {
- org: 'test-org',
- project: 'test-project',
- sourcemaps: {
- deleteSourcemapsAfterUpload: true,
+ it.each([false, undefined])('has the webpack plugin keep the client source maps when the option is %s', option => {
+ const result = getBuildPluginOptions({
+ sentryBuildOptions: {
+ org: 'test-org',
+ project: 'test-project',
+ sourcemaps: { deleteSourcemapsAfterUpload: option },
},
- };
+ releaseName: mockReleaseName,
+ distDirAbsPath: mockDistDirAbsPath,
+ buildTool: 'webpack-client',
+ });
+ expect(result.sourcemaps).toMatchObject({
+ filesToDeleteAfterUpload: undefined,
+ deleteSourcemapsAfterUpload: false,
+ });
+ });
+
+ it('does not delete client source maps in the webpack plugin with useRunAfterProductionCompileHook', () => {
const result = getBuildPluginOptions({
- sentryBuildOptions,
+ sentryBuildOptions: {
+ org: 'test-org',
+ project: 'test-project',
+ sourcemaps: { deleteSourcemapsAfterUpload: true },
+ },
releaseName: mockReleaseName,
distDirAbsPath: mockDistDirAbsPath,
buildTool: 'webpack-client',
useRunAfterProductionCompileHook: true,
});
- // File deletion should be undefined when using the hook
- expect(result.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined();
+ // The upload runs after the compile, and the after-production-compile options delete the maps.
+ expect(result.sourcemaps).toMatchObject({
+ filesToDeleteAfterUpload: undefined,
+ deleteSourcemapsAfterUpload: false,
+ });
});
- it('does not configure file deletion for server builds even when deleteSourcemapsAfterUpload is enabled', () => {
- const sentryBuildOptions: SentryBuildOptions = {
- org: 'test-org',
- project: 'test-project',
- sourcemaps: {
- deleteSourcemapsAfterUpload: true,
- },
- };
+ it.each(['webpack-nodejs', 'webpack-edge'] as const)(
+ 'does not delete source maps of %s builds even when deleteSourcemapsAfterUpload is enabled',
+ buildTool => {
+ const result = getBuildPluginOptions({
+ sentryBuildOptions: {
+ org: 'test-org',
+ project: 'test-project',
+ sourcemaps: { deleteSourcemapsAfterUpload: true },
+ },
+ releaseName: mockReleaseName,
+ distDirAbsPath: mockDistDirAbsPath,
+ buildTool,
+ });
- const serverBuildTools = ['webpack-nodejs', 'webpack-edge'] as const;
+ expect(result.sourcemaps).toMatchObject({
+ filesToDeleteAfterUpload: undefined,
+ deleteSourcemapsAfterUpload: false,
+ sdkEnabledSourceMaps: false,
+ });
+ },
+ );
- serverBuildTools.forEach(buildTool => {
+ it.each(['after-production-compile-webpack', 'after-production-compile-turbopack'] as const)(
+ 'leaves deleteSourcemapsAfterUpload unset for %s builds',
+ buildTool => {
const result = getBuildPluginOptions({
- sentryBuildOptions,
+ sentryBuildOptions: {
+ org: 'test-org',
+ project: 'test-project',
+ sourcemaps: { deleteSourcemapsAfterUpload: true },
+ },
releaseName: mockReleaseName,
distDirAbsPath: mockDistDirAbsPath,
buildTool,
});
- expect(result.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined();
- });
- });
+ expect(result.sourcemaps?.deleteSourcemapsAfterUpload).toBeUndefined();
+ expect(result.sourcemaps?.sdkEnabledSourceMaps).toBeUndefined();
+ },
+ );
- it('does not configure file deletion when deleteSourcemapsAfterUpload is disabled', () => {
- const sentryBuildOptions: SentryBuildOptions = {
- org: 'test-org',
- project: 'test-project',
- sourcemaps: {
- deleteSourcemapsAfterUpload: false,
- },
- };
-
- const result = getBuildPluginOptions({
- sentryBuildOptions,
- releaseName: mockReleaseName,
- distDirAbsPath: mockDistDirAbsPath,
- buildTool: 'webpack-client',
- });
+ it.each([true, false])(
+ 'sets sdkEnabledSourceMaps to the client delete option (deleteSourcemapsAfterUpload: %s)',
+ option => {
+ const result = getBuildPluginOptions({
+ sentryBuildOptions: {
+ org: 'test-org',
+ project: 'test-project',
+ sourcemaps: { deleteSourcemapsAfterUpload: option },
+ },
+ releaseName: mockReleaseName,
+ distDirAbsPath: mockDistDirAbsPath,
+ buildTool: 'webpack-client',
+ });
- expect(result.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined();
- });
+ expect(result.sourcemaps).toMatchObject({ deleteSourcemapsAfterUpload: option, sdkEnabledSourceMaps: option });
+ },
+ );
it('uses custom filesToDeleteAfterUpload string when provided', () => {
const sentryBuildOptions: SentryBuildOptions = {
@@ -588,6 +616,7 @@ describe('getBuildPluginOptions', () => {
});
expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual(['.next/static/**/*.map']);
+ expect(result.sourcemaps?.deleteSourcemapsAfterUpload).toBe(false);
});
it('uses custom filesToDeleteAfterUpload array when provided', () => {
@@ -627,6 +656,7 @@ describe('getBuildPluginOptions', () => {
});
expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual(['custom/**/*.map']);
+ expect(result.sourcemaps?.deleteSourcemapsAfterUpload).toBe(false);
});
it('filesToDeleteAfterUpload bypasses server build guard', () => {
diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts
index 60d06c115a29..58188e2ad17f 100644
--- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts
+++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts
@@ -99,6 +99,49 @@ describe('constructWebpackConfigFunction()', () => {
getBuildPluginOptionsSpy.mockRestore();
});
+ // Each row: the client `devtool` that Next.js hands over (`source-map` with
+ // `productionBrowserSourceMaps`), the user's `sourcemaps` options, and
+ // whether the plugin removes every client map.
+ it.each([
+ ['the SDK turns the maps on', undefined, {}, true],
+ ['`productionBrowserSourceMaps` is set', 'source-map', {}, true],
+ ['the user set `deleteSourcemapsAfterUpload: false`', 'source-map', { deleteSourcemapsAfterUpload: false }, false],
+ ['the user set `filesToDeleteAfterUpload`', undefined, { filesToDeleteAfterUpload: ['custom/**/*.map'] }, false],
+ ['`disable` is true', 'source-map', { disable: true }, false],
+ ['`disable` is "disable-upload"', 'source-map', { disable: 'disable-upload' }, false],
+ [
+ '`disable` is true and the user deletes',
+ 'source-map',
+ { disable: true, deleteSourcemapsAfterUpload: true },
+ true,
+ ],
+ [
+ '`disable` is "disable-upload" and the user deletes',
+ 'source-map',
+ { disable: 'disable-upload', deleteSourcemapsAfterUpload: true },
+ true,
+ ],
+ ] as const)('client source map deletion when %s', async (_, devtool, sourcemaps, deletesAll) => {
+ const sentryWebpackPlugin = vi.fn(() => ({ _name: 'sentry-webpack-plugin' }));
+ vi.spyOn(coreServer, 'loadModule').mockImplementation(() => ({ sentryWebpackPlugin }));
+
+ await materializeFinalWebpackConfig({
+ exportedNextConfig,
+ incomingWebpackConfig: { ...clientWebpackConfig, devtool },
+ incomingWebpackBuildContext: clientBuildContext,
+ sentryBuildTimeOptions: { sourcemaps: { ...sourcemaps } },
+ });
+
+ expect(sentryWebpackPlugin).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sourcemaps: expect.objectContaining({
+ deleteSourcemapsAfterUpload: deletesAll,
+ sdkEnabledSourceMaps: deletesAll,
+ }),
+ }),
+ );
+ });
+
it('does not auto-enable source map generation when `disable` is "disable-upload"', () => {
const finalNextConfig = materializeFinalNextConfig(
{