Fix stale dev types causing build failure after route deletion (#86489)

When running `next dev`, then stopping it, deleting a route, and running
`next build`, the build would fail with a type error like:

```
.next/dev/types/validator.ts:78:39
Type error: Cannot find module '../../../app/simple-test/page.js'
```

The root cause here is a fundamental tension: we have **one
tsconfig.json** that needs to serve **two different modes**. When
`isolatedDevBuild` is enabled (which is the default), Next.js outputs
types to different directories:
- Development: `.next/dev/types/`
- Production: `.next/types/`

To avoid tsconfig.json changing every time you switch between dev and
build (which causes git noise and forces IDE reloads), we proactively
include both paths:

```json
{
  "include": [
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts"
  ]
}
```

The problem is that TypeScript checks ALL files matching these patterns.
If `.next/dev/types/validator.ts` exists from a previous dev session and
contains imports to routes that have since been deleted, TypeScript
fails during build because those imports point to non-existent files.

You might ask: why not just completely separate them with two tsconfigs?
The issue is that would require users to know which tsconfig to use for
their IDE, and switching between configs manually defeats the purpose of
a seamless dev/build experience. The single tsconfig approach is
intentional.

The fix filters out `.next/dev/types` files programmatically when
running type check during build, without touching tsconfig.json:

```typescript
let fileNames = effectiveConfiguration.fileNames
if (excludeDevTypes) {
  const devTypesPattern = /[/\\]\.next[/\\]dev[/\\]types[/\\]/
  fileNames = fileNames.filter((fileName) => !devTypesPattern.test(fileName))
}
```

This way the tsconfig stays unchanged (no git churn, IDE stays happy),
dev types are preserved for concurrent dev sessions, and build only
type-checks its own `.next/types` directory.
This commit is contained in:
Jude Gao
2025-11-26 16:29:49 -05:00
committed by GitHub
parent 1f6391ceee
commit 52ea3cbabc
10 changed files with 267 additions and 34 deletions
@@ -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
@@ -20,14 +22,37 @@ export async function runTypeCheck(
distDir: string,
tsConfigPath: string,
cacheDir?: string,
isAppDirEnabled?: boolean
isAppDirEnabled?: boolean,
isolatedDevBuild?: boolean
): Promise<TypeCheckResult> {
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
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) {
return {
hasWarnings: false,
inputFilesCount: 0,
@@ -57,7 +82,7 @@ export async function runTypeCheck(
}
incremental = true
program = typescript.createIncrementalProgram({
rootNames: effectiveConfiguration.fileNames,
rootNames: fileNames,
options: {
...options,
composite: false,
@@ -66,10 +91,7 @@ export async function runTypeCheck(
},
})
} else {
program = typescript.createProgram(
effectiveConfiguration.fileNames,
options
)
program = typescript.createProgram(fileNames, options)
}
const result = program.emit()
@@ -147,7 +169,7 @@ export async function runTypeCheck(
return {
hasWarnings: true,
warnings,
inputFilesCount: effectiveConfiguration.fileNames.length,
inputFilesCount: fileNames.length,
totalFilesCount: program.getSourceFiles().length,
incremental,
}
@@ -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')
}
@@ -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`]
const resolvedIsolatedDevBuild =
isolatedDevBuild === undefined
? defaultConfig.experimental.isolatedDevBuild
: isolatedDevBuild
// 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)
}
// 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
@@ -158,7 +158,8 @@ export async function verifyTypeScriptSetup({
distDir,
resolvedTsConfigPath,
cacheDir,
hasAppDir
hasAppDir,
isolatedDevBuild
)
}
return { result, version: typescriptVersion }
+1 -1
View File
@@ -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')
}
@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,3 @@
export default function Page() {
return <div>Home</div>
}
@@ -0,0 +1,3 @@
export default function TempRoutePage() {
return <div>Temp Route</div>
}
@@ -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)
})
})
+99
View File
@@ -21,6 +21,105 @@ 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<string, string>
): 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<string, string>; 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('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({
exitCode: signal || code,
cliOutput: this.cliOutput.slice(curOutput),
})
})
})
}
public async start() {
if (this.childProcess) {
throw new Error('next already started')