Skip to content

Commit 52ea3cb

Browse files
authored
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.
1 parent 1f6391c commit 52ea3cb

File tree

10 files changed

+268
-35
lines changed

10 files changed

+268
-35
lines changed

packages/next/src/lib/typescript/runTypeCheck.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import path from 'path'
22
import { getFormattedDiagnostic } from './diagnosticFormatter'
33
import { getTypeScriptConfiguration } from './getTypeScriptConfiguration'
44
import { getRequiredConfiguration } from './writeConfigurationDefaults'
5+
import { getDevTypesPath } from './type-paths'
56

67
import { CompileError } from '../compile-error'
78
import { warn } from '../../build/output/log'
9+
import { defaultConfig } from '../../server/config-shared'
810

911
export interface TypeCheckResult {
1012
hasWarnings: boolean
@@ -20,14 +22,37 @@ export async function runTypeCheck(
2022
distDir: string,
2123
tsConfigPath: string,
2224
cacheDir?: string,
23-
isAppDirEnabled?: boolean
25+
isAppDirEnabled?: boolean,
26+
isolatedDevBuild?: boolean
2427
): Promise<TypeCheckResult> {
2528
const effectiveConfiguration = await getTypeScriptConfiguration(
2629
typescript,
2730
tsConfigPath
2831
)
2932

30-
if (effectiveConfiguration.fileNames.length < 1) {
33+
// When isolatedDevBuild is enabled, tsconfig includes both .next/types and
34+
// .next/dev/types to avoid config churn between dev/build modes. During build,
35+
// we filter out .next/dev/types files to prevent stale dev types from causing
36+
// errors when routes have been deleted since the last dev session.
37+
let fileNames = effectiveConfiguration.fileNames
38+
const resolvedIsolatedDevBuild =
39+
isolatedDevBuild === undefined
40+
? defaultConfig.experimental.isolatedDevBuild
41+
: isolatedDevBuild
42+
43+
// Get the dev types path to filter (null if not applicable)
44+
const devTypesDir = getDevTypesPath(
45+
baseDir,
46+
distDir,
47+
resolvedIsolatedDevBuild
48+
)
49+
if (devTypesDir) {
50+
fileNames = fileNames.filter(
51+
(fileName) => !fileName.startsWith(devTypesDir)
52+
)
53+
}
54+
55+
if (fileNames.length < 1) {
3156
return {
3257
hasWarnings: false,
3358
inputFilesCount: 0,
@@ -57,7 +82,7 @@ export async function runTypeCheck(
5782
}
5883
incremental = true
5984
program = typescript.createIncrementalProgram({
60-
rootNames: effectiveConfiguration.fileNames,
85+
rootNames: fileNames,
6186
options: {
6287
...options,
6388
composite: false,
@@ -66,10 +91,7 @@ export async function runTypeCheck(
6691
},
6792
})
6893
} else {
69-
program = typescript.createProgram(
70-
effectiveConfiguration.fileNames,
71-
options
72-
)
94+
program = typescript.createProgram(fileNames, options)
7395
}
7496

7597
const result = program.emit()
@@ -147,7 +169,7 @@ export async function runTypeCheck(
147169
return {
148170
hasWarnings: true,
149171
warnings,
150-
inputFilesCount: effectiveConfiguration.fileNames.length,
172+
inputFilesCount: fileNames.length,
151173
totalFilesCount: program.getSourceFiles().length,
152174
incremental,
153175
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import path from 'path'
2+
3+
/**
4+
* Gets the glob patterns for type definition directories in tsconfig.
5+
* When isolatedDevBuild is enabled, Next.js uses different distDir paths:
6+
* - Development: "{distDir}/dev"
7+
* - Production: "{distDir}"
8+
*/
9+
export function getTypeDefinitionGlobPatterns(
10+
distDir: string,
11+
isolatedDevBuild: boolean
12+
): string[] {
13+
const distDirPosix =
14+
path.win32.sep === path.sep
15+
? distDir.replaceAll(path.win32.sep, path.posix.sep)
16+
: distDir
17+
18+
const typeGlobPatterns: string[] = [`${distDirPosix}/types/**/*.ts`]
19+
20+
// When isolatedDevBuild is enabled, include both .next/types and .next/dev/types
21+
// to avoid tsconfig churn when switching between dev/build modes
22+
if (isolatedDevBuild) {
23+
typeGlobPatterns.push(
24+
process.env.NODE_ENV === 'development'
25+
? // In dev, distDir is "{distDir}/dev", so also include "{distDir}/types"
26+
`${distDirPosix.replace(/\/dev$/, '')}/types/**/*.ts`
27+
: // In build, distDir is "{distDir}", so also include "{distDir}/dev/types"
28+
`${distDirPosix}/dev/types/**/*.ts`
29+
)
30+
// Sort for consistent order
31+
typeGlobPatterns.sort((a, b) => a.length - b.length)
32+
}
33+
34+
return typeGlobPatterns
35+
}
36+
37+
/**
38+
* Gets the absolute path to the dev types directory for filtering during type-checking.
39+
* Returns null if isolatedDevBuild is disabled or in dev mode (where dev types are the main types).
40+
*/
41+
export function getDevTypesPath(
42+
baseDir: string,
43+
distDir: string,
44+
isolatedDevBuild: boolean
45+
): string | null {
46+
if (!isolatedDevBuild) {
47+
return null
48+
}
49+
50+
const isDev = process.env.NODE_ENV === 'development'
51+
if (isDev) {
52+
// In dev mode, dev types are the main types, so no need to filter
53+
return null
54+
}
55+
56+
// In build mode, dev types are at "{baseDir}/{distDir}/dev/types" and should be filtered
57+
return path.join(baseDir, distDir, 'dev', 'types')
58+
}

packages/next/src/lib/typescript/writeConfigurationDefaults.ts

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { readFileSync, writeFileSync } from 'fs'
2-
import * as path from 'path'
32
import { bold, cyan, white } from '../picocolors'
43
import * as CommentJson from 'next/dist/compiled/comment-json'
54
import semver from 'next/dist/compiled/semver'
65
import os from 'os'
76
import type { CompilerOptions } from 'typescript'
7+
import { getTypeDefinitionGlobPatterns } from './type-paths'
88
import * as Log from '../../build/output/log'
9+
import { defaultConfig } from '../../server/config-shared'
910

1011
type DesiredCompilerOptionsShape = {
1112
[K in keyof CompilerOptions]:
@@ -268,30 +269,17 @@ export async function writeConfigurationDefaults(
268269
}
269270
}
270271

271-
const distDirPosix =
272-
path.win32.sep === path.sep
273-
? distDir.replaceAll(path.win32.sep, path.posix.sep)
274-
: distDir
275-
const nextAppTypes: string[] = [`${distDirPosix}/types/**/*.ts`]
276-
277-
// When isolatedDevBuild is enabled, Next.js uses different distDir paths:
278-
// - Development: "{distDir}/dev"
279-
// - Production: "{distDir}"
280-
// To prevent tsconfig updates when switching between dev/build modes,
281-
// we proactively include both type paths regardless of current environment.
282-
if (isolatedDevBuild !== false) {
283-
nextAppTypes.push(
284-
process.env.NODE_ENV === 'development'
285-
? // In dev, distDir is "{distDir}/dev", which is already in the array above, but we also need "{distDir}/types".
286-
// Here we remove "/dev" at the end of distDir for consistency.
287-
`${distDirPosix.replace(/\/dev$/, '')}/types/**/*.ts`
288-
: // In build, distDir is "{distDir}", which is already in the array above, but we also need "{distDir}/dev/types".
289-
// Here we add "/dev" at the end of distDir for consistency.
290-
`${distDirPosix}/dev/types/**/*.ts`
291-
)
292-
// Sort the array to ensure consistent order.
293-
nextAppTypes.sort((a, b) => a.length - b.length)
294-
}
272+
const resolvedIsolatedDevBuild =
273+
isolatedDevBuild === undefined
274+
? defaultConfig.experimental.isolatedDevBuild
275+
: isolatedDevBuild
276+
277+
// Get type definition glob patterns using shared utility to ensure consistency
278+
// with other TypeScript infrastructure (e.g., runTypeCheck.ts)
279+
const nextAppTypes = getTypeDefinitionGlobPatterns(
280+
distDir,
281+
resolvedIsolatedDevBuild
282+
)
295283

296284
if (!('include' in userTsConfig)) {
297285
userTsConfig.include = hasAppDir

packages/next/src/lib/verify-typescript-setup.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ export async function verifyTypeScriptSetup({
158158
distDir,
159159
resolvedTsConfigPath,
160160
cacheDir,
161-
hasAppDir
161+
hasAppDir,
162+
isolatedDevBuild
162163
)
163164
}
164165
return { result, version: typescriptVersion }

packages/next/src/server/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1356,7 +1356,7 @@ function assignDefaultsAndValidate(
13561356
;(result as NextConfigComplete).distDirRoot = result.distDir
13571357
if (
13581358
phase === PHASE_DEVELOPMENT_SERVER &&
1359-
result.experimental?.isolatedDevBuild
1359+
result.experimental.isolatedDevBuild
13601360
) {
13611361
result.distDir = join(result.distDir, 'dev')
13621362
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default function RootLayout({
2+
children,
3+
}: {
4+
children: React.ReactNode
5+
}) {
6+
return (
7+
<html>
8+
<body>{children}</body>
9+
</html>
10+
)
11+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <div>Home</div>
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function TempRoutePage() {
2+
return <div>Temp Route</div>
3+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import { retry } from 'next-test-utils'
3+
4+
describe('stale-dev-types', () => {
5+
const { next } = nextTestSetup({
6+
files: __dirname,
7+
})
8+
9+
it('should not fail build when .next/dev has stale types from deleted routes', async () => {
10+
// Step 1: Wait for dev server to generate .next/dev/types/validator.ts
11+
await retry(
12+
async () => {
13+
const exists = await next
14+
.readFile('.next/dev/types/validator.ts')
15+
.then(() => true)
16+
.catch(() => false)
17+
if (!exists) {
18+
throw new Error('validator.ts not generated yet')
19+
}
20+
},
21+
5000,
22+
500
23+
)
24+
25+
// Verify validator.ts contains reference to temp-route
26+
const validatorContent = await next.readFile('.next/dev/types/validator.ts')
27+
expect(validatorContent).toContain('temp-route/page')
28+
29+
// Step 2: Stop dev server
30+
await next.stop()
31+
32+
// Step 3: Delete the temp-route (simulating user deleting a route)
33+
await next.deleteFile('app/temp-route/page.tsx')
34+
35+
// Verify .next/dev/types/validator.ts still references deleted route (stale)
36+
const staleValidator = await next.readFile('.next/dev/types/validator.ts')
37+
expect(staleValidator).toContain('temp-route/page')
38+
39+
// Step 4: Run build - should NOT fail due to stale .next/dev types
40+
const { exitCode, cliOutput } = await next.build()
41+
42+
// Build should succeed - stale dev types should be excluded from type checking
43+
expect(cliOutput).not.toContain(
44+
"Cannot find module '../../../app/temp-route/page"
45+
)
46+
expect(exitCode).toBe(0)
47+
})
48+
})

test/lib/next-modes/next-dev.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,105 @@ export class NextDevInstance extends NextInstance {
2121
return this._cliOutput || ''
2222
}
2323

24+
private handleStdio = (childProcess) => {
25+
childProcess.stdout.on('data', (chunk) => {
26+
const msg = chunk.toString()
27+
process.stdout.write(chunk)
28+
this._cliOutput += msg
29+
this.emit('stdout', [msg])
30+
})
31+
childProcess.stderr.on('data', (chunk) => {
32+
const msg = chunk.toString()
33+
process.stderr.write(chunk)
34+
this._cliOutput += msg
35+
this.emit('stderr', [msg])
36+
})
37+
}
38+
39+
private getBuildArgs(args?: string[]) {
40+
let buildArgs = ['pnpm', 'next', 'build']
41+
42+
if (this.buildCommand) {
43+
buildArgs = this.buildCommand.split(' ')
44+
}
45+
46+
if (this.buildArgs) {
47+
buildArgs.push(...this.buildArgs)
48+
}
49+
50+
if (args) {
51+
buildArgs.push(...args)
52+
}
53+
54+
if (process.env.NEXT_SKIP_ISOLATE) {
55+
// without isolation yarn can't be used and pnpm must be used instead
56+
if (buildArgs[0] === 'yarn') {
57+
buildArgs[0] = 'pnpm'
58+
}
59+
}
60+
61+
return buildArgs
62+
}
63+
64+
private getSpawnOpts(
65+
env?: Record<string, string>
66+
): import('child_process').SpawnOptions {
67+
return {
68+
cwd: this.testDir,
69+
stdio: ['ignore', 'pipe', 'pipe'],
70+
shell: false,
71+
env: {
72+
...process.env,
73+
...this.env,
74+
...env,
75+
NODE_ENV: this.env.NODE_ENV || ('' as any),
76+
PORT: this.forcedPort || '0',
77+
__NEXT_TEST_MODE: 'e2e',
78+
},
79+
}
80+
}
81+
82+
public async build(
83+
options: { env?: Record<string, string>; args?: string[] } = {}
84+
) {
85+
if (this.childProcess) {
86+
throw new Error(
87+
`can not run build while server is running, use next.stop() first`
88+
)
89+
}
90+
91+
return new Promise<{
92+
exitCode: NodeJS.Signals | number | null
93+
cliOutput: string
94+
}>((resolve) => {
95+
const curOutput = this._cliOutput.length
96+
const spawnOpts = this.getSpawnOpts(options.env)
97+
const buildArgs = this.getBuildArgs(options.args)
98+
99+
console.log('running', shellQuote(buildArgs))
100+
101+
this.childProcess = spawn(buildArgs[0], buildArgs.slice(1), spawnOpts)
102+
this.handleStdio(this.childProcess)
103+
104+
this.childProcess.on('error', (error) => {
105+
this.childProcess = undefined
106+
resolve({
107+
exitCode: 1,
108+
cliOutput:
109+
this.cliOutput.slice(curOutput) + '\nSpawn error: ' + error.message,
110+
})
111+
})
112+
113+
this.childProcess.on('exit', (code, signal) => {
114+
this.childProcess = undefined
115+
resolve({
116+
exitCode: signal || code,
117+
cliOutput: this.cliOutput.slice(curOutput),
118+
})
119+
})
120+
})
121+
}
122+
24123
public async start() {
25124
if (this.childProcess) {
26125
throw new Error('next already started')

0 commit comments

Comments
 (0)