mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user