Skip to content

Commit 49b57d1

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

File tree

8 files changed

+187
-10
lines changed

8 files changed

+187
-10
lines changed

apps/bundle-analyzer/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import './.next/types/routes.d.ts'
3+
import "./.next/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

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: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { nextTestSetup, isNextDev } from 'e2e-utils'
2+
import { retry } from 'next-test-utils'
3+
4+
describe('stale-dev-types', () => {
5+
const { next, skipped } = nextTestSetup({
6+
files: __dirname,
7+
skipDeployment: true,
8+
})
9+
10+
if (skipped) {
11+
return
12+
}
13+
14+
// This test only makes sense in dev mode - we need to generate .next/dev/types first
15+
if (!isNextDev) {
16+
it('skip in non-dev mode', () => {})
17+
return
18+
}
19+
20+
it('should not fail build when .next/dev has stale types from deleted routes', async () => {
21+
// Step 1: Wait for dev server to generate .next/dev/types/validator.ts
22+
await retry(
23+
async () => {
24+
const exists = await next
25+
.readFile('.next/dev/types/validator.ts')
26+
.then(() => true)
27+
.catch(() => false)
28+
if (!exists) {
29+
throw new Error('validator.ts not generated yet')
30+
}
31+
},
32+
5000,
33+
500
34+
)
35+
36+
// Verify validator.ts contains reference to temp-route
37+
const validatorContent = await next.readFile(
38+
'.next/dev/types/validator.ts'
39+
)
40+
expect(validatorContent).toContain('/temp-route')
41+
42+
// Step 2: Stop dev server
43+
await next.stop()
44+
45+
// Step 3: Delete the temp-route (simulating user deleting a route)
46+
await next.deleteFile('app/temp-route/page.tsx')
47+
48+
// Verify .next/dev/types/validator.ts still references deleted route (stale)
49+
const staleValidator = await next.readFile('.next/dev/types/validator.ts')
50+
expect(staleValidator).toContain('/temp-route')
51+
52+
// Step 4: Run build - should NOT fail due to stale .next/dev types
53+
const { exitCode, cliOutput } = await next.build()
54+
55+
// Build should succeed - stale dev types should be excluded from type checking
56+
expect(cliOutput).not.toContain(
57+
"Cannot find module '../../../app/temp-route/page"
58+
)
59+
expect(exitCode).toBe(0)
60+
})
61+
})

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)