diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e3d8415..9f4862c5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Bug Fixes + +- fix(nextjs): Skip `dynamic = "force-dynamic"` export in App Router routes when `cacheComponents` is enabled ([#1245](https://github.com/getsentry/sentry-wizard/pull/1245)) + ## 6.12.0 ### Features diff --git a/src/nextjs/nextjs-wizard.ts b/src/nextjs/nextjs-wizard.ts index a86750d5f..4eb7e21fc 100644 --- a/src/nextjs/nextjs-wizard.ts +++ b/src/nextjs/nextjs-wizard.ts @@ -61,6 +61,7 @@ import { hasRootLayoutFile, unwrapSentryConfigAst, wrapWithSentryConfig, + hasCacheComponentsEnabled, } from './utils'; export function runNextjsWizard(options: WizardOptions) { @@ -1121,11 +1122,14 @@ async function createExamplePage( const newRouteFileName = `route.${typeScriptDetected ? 'ts' : 'js'}`; + const cacheComponentsEnabled = hasCacheComponentsEnabled(); + await fs.promises.writeFile( path.join(appFolderPath, 'api', 'sentry-example-api', newRouteFileName), getSentryExampleAppDirApiRoute({ isTypeScript: typeScriptDetected, logsEnabled, + includeDynamic: !cacheComponentsEnabled, }), { encoding: 'utf8', flag: 'w' }, ); diff --git a/src/nextjs/templates.ts b/src/nextjs/templates.ts index 08ee1338a..583b500dc 100644 --- a/src/nextjs/templates.ts +++ b/src/nextjs/templates.ts @@ -543,9 +543,11 @@ res.status(200).json({ name: "John Doe" }); export function getSentryExampleAppDirApiRoute({ isTypeScript, logsEnabled, + includeDynamic = true, }: { isTypeScript: boolean; logsEnabled?: boolean; + includeDynamic?: boolean; }) { const sentryImport = logsEnabled ? `import * as Sentry from "@sentry/nextjs"; @@ -557,11 +559,15 @@ export function getSentryExampleAppDirApiRoute({ Sentry.logger.info("Sentry example API called");` : ''; + const dynamicExport = includeDynamic + ? `export const dynamic = "force-dynamic"; + +` + : ''; + // Note: We intentionally don't have a return statement after throw - it would be unreachable code // We also don't import NextResponse since we don't use it (Biome noUnusedImports rule) - return `${sentryImport}export const dynamic = "force-dynamic"; - -class SentryExampleAPIError extends Error { + return `${sentryImport}${dynamicExport}class SentryExampleAPIError extends Error { constructor(message${isTypeScript ? ': string | undefined' : ''}) { super(message); this.name = "SentryExampleAPIError"; diff --git a/src/nextjs/utils.ts b/src/nextjs/utils.ts index 093874060..110415cd2 100644 --- a/src/nextjs/utils.ts +++ b/src/nextjs/utils.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { major, minVersion } from 'semver'; // @ts-expect-error - magicast is ESM and TS complains about that. It works though -import { builders } from 'magicast'; +import { builders, parseModule } from 'magicast'; export function getNextJsVersionBucket(version: string | undefined) { if (!version) { @@ -25,6 +25,70 @@ export function getNextJsVersionBucket(version: string | undefined) { } } +/** + * Detects whether cacheComponents is enabled in the Next.js config. + * Returns true if cacheComponents is set to true, false otherwise. + */ +export function hasCacheComponentsEnabled(): boolean { + const nextConfigFiles = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cjs', + 'next.config.cts', + ]; + + for (const configFile of nextConfigFiles) { + const configPath = path.join(process.cwd(), configFile); + if (!fs.existsSync(configPath)) { + continue; + } + + try { + const configContent = fs.readFileSync(configPath, 'utf8'); + + // First try a simple string check for common patterns + // This catches: cacheComponents: true, experimental: { cacheComponents: true } + if ( + /cacheComponents\s*:\s*true/.test(configContent) || + /experimental\s*:\s*\{\s*cacheComponents\s*:\s*true/.test(configContent) + ) { + return true; + } + + // Try parsing with magicast for more complex cases + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const mod = parseModule(configContent); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const nextConfig = mod.exports?.default?.$type + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + mod.exports.default + : // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + mod.exports; + + // Check for cacheComponents at root level or in experimental + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (nextConfig?.cacheComponents === true) { + return true; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (nextConfig?.experimental?.cacheComponents === true) { + return true; + } + } catch { + // If magicast parsing fails, we already checked with regex above + } + } catch { + // If we can't read the file, continue to the next one + continue; + } + } + + return false; +} + export function getMaybeAppDirLocation() { const maybeAppDirPath = path.join(process.cwd(), 'app'); const maybeSrcAppDirPath = path.join(process.cwd(), 'src', 'app'); diff --git a/test/nextjs/templates.test.ts b/test/nextjs/templates.test.ts index 7721df4bb..680f9ba46 100644 --- a/test/nextjs/templates.test.ts +++ b/test/nextjs/templates.test.ts @@ -895,5 +895,35 @@ describe('Next.js code templates', () => { expect(template).not.toContain('Sentry.logger.info'); }); + + it('generates App Router API route without dynamic export when includeDynamic is false', () => { + const template = getSentryExampleAppDirApiRoute({ + isTypeScript: true, + includeDynamic: false, + }); + + expect(template).not.toContain('export const dynamic = "force-dynamic";'); + expect(template).toContain('class SentryExampleAPIError extends Error'); + expect(template).toContain('export function GET()'); + }); + + it('generates App Router API route with dynamic export when includeDynamic is true', () => { + const template = getSentryExampleAppDirApiRoute({ + isTypeScript: true, + includeDynamic: true, + }); + + expect(template).toContain('export const dynamic = "force-dynamic";'); + expect(template).toContain('class SentryExampleAPIError extends Error'); + expect(template).toContain('export function GET()'); + }); + + it('generates App Router API route with dynamic export by default', () => { + const template = getSentryExampleAppDirApiRoute({ + isTypeScript: true, + }); + + expect(template).toContain('export const dynamic = "force-dynamic";'); + }); }); }); diff --git a/test/nextjs/utils.test.ts b/test/nextjs/utils.test.ts index 6f450280f..81535a03f 100644 --- a/test/nextjs/utils.test.ts +++ b/test/nextjs/utils.test.ts @@ -5,11 +5,13 @@ import { getNextJsVersionBucket, getMaybeAppDirLocation, hasRootLayoutFile, + hasCacheComponentsEnabled, } from '../../src/nextjs/utils'; vi.mock('fs', () => ({ existsSync: vi.fn(), lstatSync: vi.fn(), + readFileSync: vi.fn(), })); describe('Next.js Utils', () => { @@ -99,4 +101,103 @@ describe('Next.js Utils', () => { expect(hasRootLayoutFile(mockAppFolderPath)).toBe(false); }); }); + + describe('hasCacheComponentsEnabled', () => { + const mockCwd = '/mock/cwd'; + let originalCwd: () => string; + + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + originalCwd = process.cwd; + process.cwd = vi.fn(() => mockCwd); + vi.clearAllMocks(); + }); + + afterEach(() => { + process.cwd = originalCwd; + }); + + it('returns true when cacheComponents is enabled at root level', () => { + (fs.existsSync as Mock).mockImplementation((filePath: string) => { + return filePath === '/mock/cwd/next.config.js'; + }); + (fs.readFileSync as Mock).mockReturnValue(` + /** @type {import('next').NextConfig} */ + const nextConfig = { + cacheComponents: true + }; + module.exports = nextConfig; + `); + + expect(hasCacheComponentsEnabled()).toBe(true); + }); + + it('returns true when cacheComponents is enabled in experimental', () => { + (fs.existsSync as Mock).mockImplementation((filePath: string) => { + return filePath === '/mock/cwd/next.config.mjs'; + }); + (fs.readFileSync as Mock).mockReturnValue(` + /** @type {import('next').NextConfig} */ + const nextConfig = { + experimental: { + cacheComponents: true + } + }; + export default nextConfig; + `); + + expect(hasCacheComponentsEnabled()).toBe(true); + }); + + it('returns false when cacheComponents is false', () => { + (fs.existsSync as Mock).mockImplementation((filePath: string) => { + return filePath === '/mock/cwd/next.config.js'; + }); + (fs.readFileSync as Mock).mockReturnValue(` + /** @type {import('next').NextConfig} */ + const nextConfig = { + cacheComponents: false + }; + module.exports = nextConfig; + `); + + expect(hasCacheComponentsEnabled()).toBe(false); + }); + + it('returns false when no next.config file exists', () => { + (fs.existsSync as Mock).mockReturnValue(false); + + expect(hasCacheComponentsEnabled()).toBe(false); + }); + + it('returns false when config file exists but cacheComponents is not set', () => { + (fs.existsSync as Mock).mockImplementation((filePath: string) => { + return filePath === '/mock/cwd/next.config.js'; + }); + (fs.readFileSync as Mock).mockReturnValue(` + /** @type {import('next').NextConfig} */ + const nextConfig = { + reactStrictMode: true + }; + module.exports = nextConfig; + `); + + expect(hasCacheComponentsEnabled()).toBe(false); + }); + + it('handles TypeScript config files', () => { + (fs.existsSync as Mock).mockImplementation((filePath: string) => { + return filePath === '/mock/cwd/next.config.ts'; + }); + (fs.readFileSync as Mock).mockReturnValue(` + import type { NextConfig } from 'next'; + const config: NextConfig = { + cacheComponents: true + }; + export default config; + `); + + expect(hasCacheComponentsEnabled()).toBe(true); + }); + }); });