From d2a5092a7b7c65061ba919714476300c71a5d35e Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Tue, 25 Nov 2025 10:00:11 -0500 Subject: [PATCH 1/3] Fix stale dev types causing build failure after route deletion --- .../next/src/lib/typescript/runTypeCheck.ts | 24 +++-- .../next/src/lib/verify-typescript-setup.ts | 3 +- .../stale-dev-types/app/layout.tsx | 11 +++ test/development/stale-dev-types/app/page.tsx | 3 + .../stale-dev-types/app/temp-route/page.tsx | 3 + .../stale-dev-types/stale-dev-types.test.ts | 48 ++++++++++ test/lib/next-modes/next-dev.ts | 90 +++++++++++++++++++ 7 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 test/development/stale-dev-types/app/layout.tsx create mode 100644 test/development/stale-dev-types/app/page.tsx create mode 100644 test/development/stale-dev-types/app/temp-route/page.tsx create mode 100644 test/development/stale-dev-types/stale-dev-types.test.ts diff --git a/packages/next/src/lib/typescript/runTypeCheck.ts b/packages/next/src/lib/typescript/runTypeCheck.ts index efae53ad441fe5..5bce949327b737 100644 --- a/packages/next/src/lib/typescript/runTypeCheck.ts +++ b/packages/next/src/lib/typescript/runTypeCheck.ts @@ -20,14 +20,25 @@ export async function runTypeCheck( distDir: string, tsConfigPath: string, cacheDir?: string, - isAppDirEnabled?: boolean + isAppDirEnabled?: boolean, + isolatedDevBuild?: boolean ): Promise { const effectiveConfiguration = await getTypeScriptConfiguration( typescript, tsConfigPath ) - if (effectiveConfiguration.fileNames.length < 1) { + // When isolatedDevBuild is enabled, tsconfig includes both .next/types and + // .next/dev/types to avoid config churn between dev/build modes. During build, + // we filter out .next/dev/types files to prevent stale dev types from causing + // errors when routes have been deleted since the last dev session. + let fileNames = effectiveConfiguration.fileNames + if (isolatedDevBuild !== false) { + const devTypesPattern = /[/\\]\.next[/\\]dev[/\\]types[/\\]/ + fileNames = fileNames.filter((fileName) => !devTypesPattern.test(fileName)) + } + + if (fileNames.length < 1) { return { hasWarnings: false, inputFilesCount: 0, @@ -57,7 +68,7 @@ export async function runTypeCheck( } incremental = true program = typescript.createIncrementalProgram({ - rootNames: effectiveConfiguration.fileNames, + rootNames: fileNames, options: { ...options, composite: false, @@ -66,10 +77,7 @@ export async function runTypeCheck( }, }) } else { - program = typescript.createProgram( - effectiveConfiguration.fileNames, - options - ) + program = typescript.createProgram(fileNames, options) } const result = program.emit() @@ -147,7 +155,7 @@ export async function runTypeCheck( return { hasWarnings: true, warnings, - inputFilesCount: effectiveConfiguration.fileNames.length, + inputFilesCount: fileNames.length, totalFilesCount: program.getSourceFiles().length, incremental, } diff --git a/packages/next/src/lib/verify-typescript-setup.ts b/packages/next/src/lib/verify-typescript-setup.ts index 1ae4f317d0b47a..bcd55423cd0917 100644 --- a/packages/next/src/lib/verify-typescript-setup.ts +++ b/packages/next/src/lib/verify-typescript-setup.ts @@ -158,7 +158,8 @@ export async function verifyTypeScriptSetup({ distDir, resolvedTsConfigPath, cacheDir, - hasAppDir + hasAppDir, + isolatedDevBuild ) } return { result, version: typescriptVersion } diff --git a/test/development/stale-dev-types/app/layout.tsx b/test/development/stale-dev-types/app/layout.tsx new file mode 100644 index 00000000000000..08eaa94fdc8896 --- /dev/null +++ b/test/development/stale-dev-types/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/development/stale-dev-types/app/page.tsx b/test/development/stale-dev-types/app/page.tsx new file mode 100644 index 00000000000000..0ea792fc5ececc --- /dev/null +++ b/test/development/stale-dev-types/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Home
+} diff --git a/test/development/stale-dev-types/app/temp-route/page.tsx b/test/development/stale-dev-types/app/temp-route/page.tsx new file mode 100644 index 00000000000000..d656d9c1b65485 --- /dev/null +++ b/test/development/stale-dev-types/app/temp-route/page.tsx @@ -0,0 +1,3 @@ +export default function TempRoutePage() { + return
Temp Route
+} diff --git a/test/development/stale-dev-types/stale-dev-types.test.ts b/test/development/stale-dev-types/stale-dev-types.test.ts new file mode 100644 index 00000000000000..cdd2f81b298c38 --- /dev/null +++ b/test/development/stale-dev-types/stale-dev-types.test.ts @@ -0,0 +1,48 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('stale-dev-types', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should not fail build when .next/dev has stale types from deleted routes', async () => { + // Step 1: Wait for dev server to generate .next/dev/types/validator.ts + await retry( + async () => { + const exists = await next + .readFile('.next/dev/types/validator.ts') + .then(() => true) + .catch(() => false) + if (!exists) { + throw new Error('validator.ts not generated yet') + } + }, + 5000, + 500 + ) + + // Verify validator.ts contains reference to temp-route + const validatorContent = await next.readFile('.next/dev/types/validator.ts') + expect(validatorContent).toContain('temp-route/page') + + // Step 2: Stop dev server + await next.stop() + + // Step 3: Delete the temp-route (simulating user deleting a route) + await next.deleteFile('app/temp-route/page.tsx') + + // Verify .next/dev/types/validator.ts still references deleted route (stale) + const staleValidator = await next.readFile('.next/dev/types/validator.ts') + expect(staleValidator).toContain('temp-route/page') + + // Step 4: Run build - should NOT fail due to stale .next/dev types + const { exitCode, cliOutput } = await next.build() + + // Build should succeed - stale dev types should be excluded from type checking + expect(cliOutput).not.toContain( + "Cannot find module '../../../app/temp-route/page" + ) + expect(exitCode).toBe(0) + }) +}) diff --git a/test/lib/next-modes/next-dev.ts b/test/lib/next-modes/next-dev.ts index efe2f91debdec6..d6cdb0b55903e0 100644 --- a/test/lib/next-modes/next-dev.ts +++ b/test/lib/next-modes/next-dev.ts @@ -21,6 +21,96 @@ export class NextDevInstance extends NextInstance { return this._cliOutput || '' } + private handleStdio = (childProcess) => { + childProcess.stdout.on('data', (chunk) => { + const msg = chunk.toString() + process.stdout.write(chunk) + this._cliOutput += msg + this.emit('stdout', [msg]) + }) + childProcess.stderr.on('data', (chunk) => { + const msg = chunk.toString() + process.stderr.write(chunk) + this._cliOutput += msg + this.emit('stderr', [msg]) + }) + } + + private getBuildArgs(args?: string[]) { + let buildArgs = ['pnpm', 'next', 'build'] + + if (this.buildCommand) { + buildArgs = this.buildCommand.split(' ') + } + + if (this.buildArgs) { + buildArgs.push(...this.buildArgs) + } + + if (args) { + buildArgs.push(...args) + } + + if (process.env.NEXT_SKIP_ISOLATE) { + // without isolation yarn can't be used and pnpm must be used instead + if (buildArgs[0] === 'yarn') { + buildArgs[0] = 'pnpm' + } + } + + return buildArgs + } + + private getSpawnOpts( + env?: Record + ): import('child_process').SpawnOptions { + return { + cwd: this.testDir, + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + env: { + ...process.env, + ...this.env, + ...env, + NODE_ENV: this.env.NODE_ENV || ('' as any), + PORT: this.forcedPort || '0', + __NEXT_TEST_MODE: 'e2e', + }, + } + } + + public async build( + options: { env?: Record; args?: string[] } = {} + ) { + if (this.childProcess) { + throw new Error( + `can not run build while server is running, use next.stop() first` + ) + } + + return new Promise<{ + exitCode: NodeJS.Signals | number | null + cliOutput: string + }>((resolve) => { + const curOutput = this._cliOutput.length + const spawnOpts = this.getSpawnOpts(options.env) + const buildArgs = this.getBuildArgs(options.args) + + console.log('running', shellQuote(buildArgs)) + + this.childProcess = spawn(buildArgs[0], buildArgs.slice(1), spawnOpts) + this.handleStdio(this.childProcess) + + this.childProcess.on('exit', (code, signal) => { + this.childProcess = undefined + resolve({ + exitCode: signal || code, + cliOutput: this.cliOutput.slice(curOutput), + }) + }) + }) + } + public async start() { if (this.childProcess) { throw new Error('next already started') From 7f6506b438971d1b894f463ef5613d91ab6b7e05 Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Tue, 25 Nov 2025 16:58:06 -0500 Subject: [PATCH 2/3] address comments --- .../next/src/lib/typescript/runTypeCheck.ts | 20 ++++++- .../next/src/lib/typescript/type-paths.ts | 58 +++++++++++++++++++ .../typescript/writeConfigurationDefaults.ts | 38 +++++------- packages/next/src/server/config.ts | 2 +- 4 files changed, 89 insertions(+), 29 deletions(-) create mode 100644 packages/next/src/lib/typescript/type-paths.ts diff --git a/packages/next/src/lib/typescript/runTypeCheck.ts b/packages/next/src/lib/typescript/runTypeCheck.ts index 5bce949327b737..2dc8dd74eab6fa 100644 --- a/packages/next/src/lib/typescript/runTypeCheck.ts +++ b/packages/next/src/lib/typescript/runTypeCheck.ts @@ -2,9 +2,11 @@ import path from 'path' import { getFormattedDiagnostic } from './diagnosticFormatter' import { getTypeScriptConfiguration } from './getTypeScriptConfiguration' import { getRequiredConfiguration } from './writeConfigurationDefaults' +import { getDevTypesPath } from './type-paths' import { CompileError } from '../compile-error' import { warn } from '../../build/output/log' +import { defaultConfig } from '../../server/config-shared' export interface TypeCheckResult { hasWarnings: boolean @@ -33,9 +35,21 @@ export async function runTypeCheck( // we filter out .next/dev/types files to prevent stale dev types from causing // errors when routes have been deleted since the last dev session. let fileNames = effectiveConfiguration.fileNames - if (isolatedDevBuild !== false) { - const devTypesPattern = /[/\\]\.next[/\\]dev[/\\]types[/\\]/ - fileNames = fileNames.filter((fileName) => !devTypesPattern.test(fileName)) + const resolvedIsolatedDevBuild = + isolatedDevBuild === undefined + ? defaultConfig.experimental.isolatedDevBuild + : isolatedDevBuild + + // Get the dev types path to filter (null if not applicable) + const devTypesDir = getDevTypesPath( + baseDir, + distDir, + resolvedIsolatedDevBuild + ) + if (devTypesDir) { + fileNames = fileNames.filter( + (fileName) => !fileName.startsWith(devTypesDir) + ) } if (fileNames.length < 1) { diff --git a/packages/next/src/lib/typescript/type-paths.ts b/packages/next/src/lib/typescript/type-paths.ts new file mode 100644 index 00000000000000..84e51265308fa4 --- /dev/null +++ b/packages/next/src/lib/typescript/type-paths.ts @@ -0,0 +1,58 @@ +import path from 'path' + +/** + * Gets the glob patterns for type definition directories in tsconfig. + * When isolatedDevBuild is enabled, Next.js uses different distDir paths: + * - Development: "{distDir}/dev" + * - Production: "{distDir}" + */ +export function getTypeDefinitionGlobPatterns( + distDir: string, + isolatedDevBuild: boolean +): string[] { + const distDirPosix = + path.win32.sep === path.sep + ? distDir.replaceAll(path.win32.sep, path.posix.sep) + : distDir + + const typeGlobPatterns: string[] = [`${distDirPosix}/types/**/*.ts`] + + // When isolatedDevBuild is enabled, include both .next/types and .next/dev/types + // to avoid tsconfig churn when switching between dev/build modes + if (isolatedDevBuild) { + typeGlobPatterns.push( + process.env.NODE_ENV === 'development' + ? // In dev, distDir is "{distDir}/dev", so also include "{distDir}/types" + `${distDirPosix.replace(/\/dev$/, '')}/types/**/*.ts` + : // In build, distDir is "{distDir}", so also include "{distDir}/dev/types" + `${distDirPosix}/dev/types/**/*.ts` + ) + // Sort for consistent order + typeGlobPatterns.sort((a, b) => a.length - b.length) + } + + return typeGlobPatterns +} + +/** + * Gets the absolute path to the dev types directory for filtering during type-checking. + * Returns null if isolatedDevBuild is disabled or in dev mode (where dev types are the main types). + */ +export function getDevTypesPath( + baseDir: string, + distDir: string, + isolatedDevBuild: boolean +): string | null { + if (!isolatedDevBuild) { + return null + } + + const isDev = process.env.NODE_ENV === 'development' + if (isDev) { + // In dev mode, dev types are the main types, so no need to filter + return null + } + + // In build mode, dev types are at "{baseDir}/{distDir}/dev/types" and should be filtered + return path.join(baseDir, distDir, 'dev', 'types') +} diff --git a/packages/next/src/lib/typescript/writeConfigurationDefaults.ts b/packages/next/src/lib/typescript/writeConfigurationDefaults.ts index 7a166c5b677914..e8f452dc6e8f34 100644 --- a/packages/next/src/lib/typescript/writeConfigurationDefaults.ts +++ b/packages/next/src/lib/typescript/writeConfigurationDefaults.ts @@ -1,11 +1,12 @@ import { readFileSync, writeFileSync } from 'fs' -import * as path from 'path' import { bold, cyan, white } from '../picocolors' import * as CommentJson from 'next/dist/compiled/comment-json' import semver from 'next/dist/compiled/semver' import os from 'os' import type { CompilerOptions } from 'typescript' +import { getTypeDefinitionGlobPatterns } from './type-paths' import * as Log from '../../build/output/log' +import { defaultConfig } from '../../server/config-shared' type DesiredCompilerOptionsShape = { [K in keyof CompilerOptions]: @@ -268,30 +269,17 @@ export async function writeConfigurationDefaults( } } - const distDirPosix = - path.win32.sep === path.sep - ? distDir.replaceAll(path.win32.sep, path.posix.sep) - : distDir - const nextAppTypes: string[] = [`${distDirPosix}/types/**/*.ts`] - - // When isolatedDevBuild is enabled, Next.js uses different distDir paths: - // - Development: "{distDir}/dev" - // - Production: "{distDir}" - // To prevent tsconfig updates when switching between dev/build modes, - // we proactively include both type paths regardless of current environment. - if (isolatedDevBuild !== false) { - nextAppTypes.push( - process.env.NODE_ENV === 'development' - ? // In dev, distDir is "{distDir}/dev", which is already in the array above, but we also need "{distDir}/types". - // Here we remove "/dev" at the end of distDir for consistency. - `${distDirPosix.replace(/\/dev$/, '')}/types/**/*.ts` - : // In build, distDir is "{distDir}", which is already in the array above, but we also need "{distDir}/dev/types". - // Here we add "/dev" at the end of distDir for consistency. - `${distDirPosix}/dev/types/**/*.ts` - ) - // Sort the array to ensure consistent order. - nextAppTypes.sort((a, b) => a.length - b.length) - } + const resolvedIsolatedDevBuild = + isolatedDevBuild === undefined + ? defaultConfig.experimental.isolatedDevBuild + : isolatedDevBuild + + // Get type definition glob patterns using shared utility to ensure consistency + // with other TypeScript infrastructure (e.g., runTypeCheck.ts) + const nextAppTypes = getTypeDefinitionGlobPatterns( + distDir, + resolvedIsolatedDevBuild + ) if (!('include' in userTsConfig)) { userTsConfig.include = hasAppDir diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 8c756a7d32a484..35d70b68acb80f 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -1356,7 +1356,7 @@ function assignDefaultsAndValidate( ;(result as NextConfigComplete).distDirRoot = result.distDir if ( phase === PHASE_DEVELOPMENT_SERVER && - result.experimental?.isolatedDevBuild + result.experimental.isolatedDevBuild ) { result.distDir = join(result.distDir, 'dev') } From 5765d1cff609488272fd0f9f0c6a14c04a63d194 Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Tue, 25 Nov 2025 17:30:50 -0500 Subject: [PATCH 3/3] apply vade comment --- test/lib/next-modes/next-dev.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/lib/next-modes/next-dev.ts b/test/lib/next-modes/next-dev.ts index d6cdb0b55903e0..22c40864e1e50e 100644 --- a/test/lib/next-modes/next-dev.ts +++ b/test/lib/next-modes/next-dev.ts @@ -101,6 +101,15 @@ export class NextDevInstance extends NextInstance { this.childProcess = spawn(buildArgs[0], buildArgs.slice(1), spawnOpts) this.handleStdio(this.childProcess) + this.childProcess.on('error', (error) => { + this.childProcess = undefined + resolve({ + exitCode: 1, + cliOutput: + this.cliOutput.slice(curOutput) + '\nSpawn error: ' + error.message, + }) + }) + this.childProcess.on('exit', (code, signal) => { this.childProcess = undefined resolve({