Skip to content

Commit d2a5092

Browse files
committed
Fix stale dev types causing build failure after route deletion
1 parent 0daf667 commit d2a5092

File tree

7 files changed

+173
-9
lines changed

7 files changed

+173
-9
lines changed

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

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,25 @@ export async function runTypeCheck(
2020
distDir: string,
2121
tsConfigPath: string,
2222
cacheDir?: string,
23-
isAppDirEnabled?: boolean
23+
isAppDirEnabled?: boolean,
24+
isolatedDevBuild?: boolean
2425
): Promise<TypeCheckResult> {
2526
const effectiveConfiguration = await getTypeScriptConfiguration(
2627
typescript,
2728
tsConfigPath
2829
)
2930

30-
if (effectiveConfiguration.fileNames.length < 1) {
31+
// When isolatedDevBuild is enabled, tsconfig includes both .next/types and
32+
// .next/dev/types to avoid config churn between dev/build modes. During build,
33+
// we filter out .next/dev/types files to prevent stale dev types from causing
34+
// errors when routes have been deleted since the last dev session.
35+
let fileNames = effectiveConfiguration.fileNames
36+
if (isolatedDevBuild !== false) {
37+
const devTypesPattern = /[/\\]\.next[/\\]dev[/\\]types[/\\]/
38+
fileNames = fileNames.filter((fileName) => !devTypesPattern.test(fileName))
39+
}
40+
41+
if (fileNames.length < 1) {
3142
return {
3243
hasWarnings: false,
3344
inputFilesCount: 0,
@@ -57,7 +68,7 @@ export async function runTypeCheck(
5768
}
5869
incremental = true
5970
program = typescript.createIncrementalProgram({
60-
rootNames: effectiveConfiguration.fileNames,
71+
rootNames: fileNames,
6172
options: {
6273
...options,
6374
composite: false,
@@ -66,10 +77,7 @@ export async function runTypeCheck(
6677
},
6778
})
6879
} else {
69-
program = typescript.createProgram(
70-
effectiveConfiguration.fileNames,
71-
options
72-
)
80+
program = typescript.createProgram(fileNames, options)
7381
}
7482

7583
const result = program.emit()
@@ -147,7 +155,7 @@ export async function runTypeCheck(
147155
return {
148156
hasWarnings: true,
149157
warnings,
150-
inputFilesCount: effectiveConfiguration.fileNames.length,
158+
inputFilesCount: fileNames.length,
151159
totalFilesCount: program.getSourceFiles().length,
152160
incremental,
153161
}

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 }
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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,96 @@ 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('exit', (code, signal) => {
105+
this.childProcess = undefined
106+
resolve({
107+
exitCode: signal || code,
108+
cliOutput: this.cliOutput.slice(curOutput),
109+
})
110+
})
111+
})
112+
}
113+
24114
public async start() {
25115
if (this.childProcess) {
26116
throw new Error('next already started')

0 commit comments

Comments
 (0)