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( {