From 8d7fc5a8c8932aec9e8c37c4d444a00ec84cc543 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 24 Mar 2026 17:13:07 +0000 Subject: [PATCH 1/2] fix(nextjs): Skip dynamic export in App Router routes when cacheComponents enabled - Add hasCacheComponentsEnabled() utility to detect cacheComponents in Next.js config - Update getSentryExampleAppDirApiRoute() to accept includeDynamic parameter - Skip 'export const dynamic = "force-dynamic"' when cacheComponents is enabled - Add comprehensive tests for cacheComponents detection and dynamic export control - Resolves compatibility issue with Next.js 15/16+ Cache Components When cacheComponents is enabled in next.config, Next.js fails at compile time with error: 'Route segment config "dynamic" is not compatible with nextConfig.cacheComponents. Please remove it.' The wizard now detects cacheComponents and omits the dynamic export in generated App Router route files (sentry-example-api), preventing this build failure. Co-Authored-By: Claude Sonnet 4.5 Co-authored-by: Kyle a.k.a. TechSquidTV --- CHANGELOG.md | 6 ++ src/nextjs/nextjs-wizard.ts | 4 ++ src/nextjs/templates.ts | 12 +++- src/nextjs/utils.ts | 66 +++++++++++++++++++++- test/nextjs/templates.test.ts | 30 ++++++++++ test/nextjs/utils.test.ts | 101 ++++++++++++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e3d8415..8af249a88 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 ([#PR](link)) + ## 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); + }); + }); }); From c77bbd21fc4555d7cd71324ab5cfbfe4e1df9f05 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 24 Mar 2026 17:13:57 +0000 Subject: [PATCH 2/2] docs: Update CHANGELOG with PR number Co-authored-by: Kyle a.k.a. TechSquidTV --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af249a88..9f4862c5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Bug Fixes -- fix(nextjs): Skip `dynamic = "force-dynamic"` export in App Router routes when `cacheComponents` is enabled ([#PR](link)) +- 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