Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions packages/next/src/lib/typescript/runTypeCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -57,7 +82,7 @@ export async function runTypeCheck(
}
incremental = true
program = typescript.createIncrementalProgram({
rootNames: effectiveConfiguration.fileNames,
rootNames: fileNames,
options: {
...options,
composite: false,
Expand All @@ -66,10 +91,7 @@ export async function runTypeCheck(
},
})
} else {
program = typescript.createProgram(
effectiveConfiguration.fileNames,
options
)
program = typescript.createProgram(fileNames, options)
}

const result = program.emit()
Expand Down Expand Up @@ -147,7 +169,7 @@ export async function runTypeCheck(
return {
hasWarnings: true,
warnings,
inputFilesCount: effectiveConfiguration.fileNames.length,
inputFilesCount: fileNames.length,
totalFilesCount: program.getSourceFiles().length,
incremental,
}
Expand Down
58 changes: 58 additions & 0 deletions packages/next/src/lib/typescript/type-paths.ts
Original file line number Diff line number Diff line change
@@ -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')
}
38 changes: 13 additions & 25 deletions packages/next/src/lib/typescript/writeConfigurationDefaults.ts
Original file line number Diff line number Diff line change
@@ -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]:
Expand Down Expand Up @@ -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`]

// 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)
}
const resolvedIsolatedDevBuild =
isolatedDevBuild === undefined
? defaultConfig.experimental.isolatedDevBuild
: isolatedDevBuild

// 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
Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/lib/verify-typescript-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ export async function verifyTypeScriptSetup({
distDir,
resolvedTsConfigPath,
cacheDir,
hasAppDir
hasAppDir,
isolatedDevBuild
)
}
return { result, version: typescriptVersion }
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down
11 changes: 11 additions & 0 deletions test/development/stale-dev-types/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>{children}</body>
</html>
)
}
3 changes: 3 additions & 0 deletions test/development/stale-dev-types/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>Home</div>
}
3 changes: 3 additions & 0 deletions test/development/stale-dev-types/app/temp-route/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function TempRoutePage() {
return <div>Temp Route</div>
}
48 changes: 48 additions & 0 deletions test/development/stale-dev-types/stale-dev-types.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 99 additions & 0 deletions test/lib/next-modes/next-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We got the first use case that required doing a production build based on the dev setup, so filling in the unimplemented build method here.

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)
Comment on lines +101 to +102
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spawn process lacks error handling. If spawn() fails to start the child process (e.g., command not found, permission denied), the childProcess.on('exit') handler will never fire, causing the promise to hang indefinitely and the test to timeout.

Fix by adding error handling:

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
  })
})
Suggested change
this.childProcess = spawn(buildArgs[0], buildArgs.slice(1), spawnOpts)
this.handleStdio(this.childProcess)
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
})
})

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks good to add as well


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')
Expand Down
Loading