Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/nextjs/nextjs-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
hasRootLayoutFile,
unwrapSentryConfigAst,
wrapWithSentryConfig,
hasCacheComponentsEnabled,
} from './utils';

export function runNextjsWizard(options: WizardOptions) {
Expand Down Expand Up @@ -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' },
);
Expand Down
12 changes: 9 additions & 3 deletions src/nextjs/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing blank line between import and class declaration

Low Severity

When includeDynamic is false and logsEnabled is true, the sentryImport string ends with a single newline and dynamicExport is empty, so the generated file has no blank line between the import statement and the class SentryExampleAPIError declaration. Previously, the dynamicExport content (which ended with \n\n) always provided that visual and linter-expected separation. This could trigger lint rules like import/newline-after-import in the generated example file.

Fix in Cursor Fix in Web

constructor(message${isTypeScript ? ': string | undefined' : ''}) {
super(message);
this.name = "SentryExampleAPIError";
Expand Down
66 changes: 65 additions & 1 deletion src/nextjs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second regex condition is entirely redundant

Low Severity

The second regex /experimental\s*:\s*\{\s*cacheComponents\s*:\s*true/ is entirely redundant because the first regex /cacheComponents\s*:\s*true/ is a strict superset — any string matching the second pattern will always match the first. The || condition means the second branch can never be reached without the first already succeeding.

Fix in Cursor Fix in Web

) {
return true;
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regex matches cacheComponents in comments causing false positives

Low Severity

The regex /cacheComponents\s*:\s*true/ matches anywhere in file content, including comments like // cacheComponents: true or strings. Because this check runs before the more accurate magicast AST parsing and short-circuits with return true, a commented-out cacheComponents: true line causes hasCacheComponentsEnabled() to incorrectly report the feature as enabled. This would skip the dynamic export even when cacheComponents isn't actually turned on. The impact is low since Route Handlers are dynamic by default, but the detection logic is logically incorrect.

Fix in Cursor Fix in Web


// 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');
Expand Down
30 changes: 30 additions & 0 deletions test/nextjs/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";');
});
});
});
101 changes: 101 additions & 0 deletions test/nextjs/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
Loading