Skip to content

Commit bcc81b8

Browse files
Timerrokinsky
authored andcommitted
Replace fork-ts-checker-webpack-plugin with faster alternative (vercel#13529)
This removes `fork-ts-checker-webpack-plugin` and instead directly calls the TypeScript API. This is approximately 10x faster. Base build: 7s (no TypeScript features enabled) - `[email protected]`: 90s, computer sounds like an airplane - `[email protected]`: 84s, computer did **not** sound like an airplane - `[email protected]`: 90s, regressed - `npx tsc -p tsconfig.json --noEmit`: 12s (time: `18.57s user 0.97s system 169% cpu 11.525 total`) - **This PR**: 22s, expected to get better when we run this as a side-car All of these tests were run 3 times and repeat-accurate within +/- 0.5s.
1 parent 782baab commit bcc81b8

File tree

12 files changed

+186
-107
lines changed

12 files changed

+186
-107
lines changed

packages/next/build/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { __ApiPreviewProps } from '../next-server/server/api-utils'
4747
import loadConfig, {
4848
isTargetLikeServerless,
4949
} from '../next-server/server/config'
50+
import { BuildManifest } from '../next-server/server/get-page-files'
5051
import { normalizePagePath } from '../next-server/server/normalize-page-path'
5152
import * as ciEnvironment from '../telemetry/ci-info'
5253
import {
@@ -64,17 +65,16 @@ import createSpinner from './spinner'
6465
import {
6566
collectPages,
6667
getJsPageSizeInKb,
68+
getNamedExports,
6769
hasCustomGetInitialProps,
6870
isPageStatic,
6971
PageInfo,
7072
printCustomRoutes,
7173
printTreeView,
72-
getNamedExports,
7374
} from './utils'
7475
import getBaseWebpackConfig from './webpack-config'
75-
import { writeBuildId } from './write-build-id'
7676
import { PagesManifest } from './webpack/plugins/pages-manifest-plugin'
77-
import { BuildManifest } from '../next-server/server/get-page-files'
77+
import { writeBuildId } from './write-build-id'
7878

7979
const staticCheckWorker = require.resolve('./utils')
8080

@@ -174,7 +174,8 @@ export default async function build(dir: string, conf = null): Promise<void> {
174174

175175
eventNextPlugins(path.resolve(dir)).then((events) => telemetry.record(events))
176176

177-
await verifyTypeScriptSetup(dir, pagesDir)
177+
const ignoreTypeScriptErrors = Boolean(config.typescript?.ignoreBuildErrors)
178+
await verifyTypeScriptSetup(dir, pagesDir, !ignoreTypeScriptErrors)
178179

179180
try {
180181
await promises.stat(publicDir)

packages/next/build/webpack-config.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import ReactRefreshWebpackPlugin from '@next/react-refresh-utils/ReactRefreshWebpackPlugin'
22
import crypto from 'crypto'
3-
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'
43
import { readFileSync } from 'fs'
54
import chalk from 'next/dist/compiled/chalk'
65
import TerserPlugin from 'next/dist/compiled/terser-webpack-plugin'
@@ -247,8 +246,6 @@ export default async function getBaseWebpackConfig(
247246
const useTypeScript = Boolean(
248247
typeScriptPath && (await fileExists(tsConfigPath))
249248
)
250-
const ignoreTypeScriptErrors =
251-
dev || Boolean(config.typescript?.ignoreBuildErrors)
252249

253250
let jsConfig
254251
// jsconfig is a subset of tsconfig
@@ -972,28 +969,10 @@ export default async function getBaseWebpackConfig(
972969
new ProfilingPlugin({
973970
tracer,
974971
}),
975-
!dev &&
976-
!isServer &&
977-
useTypeScript &&
978-
!ignoreTypeScriptErrors &&
979-
new ForkTsCheckerWebpackPlugin(
980-
PnpWebpackPlugin.forkTsCheckerOptions({
981-
typescript: typeScriptPath,
982-
async: false,
983-
useTypescriptIncrementalApi: true,
984-
checkSyntacticErrors: true,
985-
tsconfig: tsConfigPath,
986-
reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'],
987-
compilerOptions: { isolatedModules: true, noEmit: true },
988-
silent: true,
989-
formatter: 'codeframe',
990-
})
991-
),
992972
config.experimental.modern &&
993973
!isServer &&
994974
!dev &&
995975
new NextEsmPlugin({
996-
excludedPlugins: ['ForkTsCheckerWebpackPlugin'],
997976
filename: (getFileName: Function | string) => (...args: any[]) => {
998977
const name =
999978
typeof getFileName === 'function'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export class TypeScriptCompileError extends Error {}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { codeFrameColumns } from '@babel/code-frame'
2+
import chalk from 'next/dist/compiled/chalk'
3+
import path from 'path'
4+
5+
export enum DiagnosticCategory {
6+
Warning = 0,
7+
Error = 1,
8+
Suggestion = 2,
9+
Message = 3,
10+
}
11+
12+
export async function getFormattedDiagnostic(
13+
ts: typeof import('typescript'),
14+
baseDir: string,
15+
diagnostic: import('typescript').Diagnostic
16+
): Promise<string> {
17+
let message = ''
18+
19+
const reason = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
20+
const category = diagnostic.category
21+
switch (category) {
22+
// Warning
23+
case DiagnosticCategory.Warning: {
24+
message += chalk.yellow.bold('Type warning') + ': '
25+
break
26+
}
27+
// Error
28+
case DiagnosticCategory.Error: {
29+
message += chalk.red.bold('Type error') + ': '
30+
break
31+
}
32+
// 2 = Suggestion, 3 = Message
33+
case DiagnosticCategory.Suggestion:
34+
case DiagnosticCategory.Message:
35+
default: {
36+
message += chalk.cyan.bold(category === 2 ? 'Suggestion' : 'Info') + ': '
37+
break
38+
}
39+
}
40+
message += reason + '\n'
41+
42+
if (diagnostic.file) {
43+
const pos = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start!)
44+
const line = pos.line + 1
45+
const character = pos.character + 1
46+
47+
let fileName = path.posix.normalize(
48+
path.relative(baseDir, diagnostic.file.fileName).replace(/\\/, '/')
49+
)
50+
if (!fileName.startsWith('.')) {
51+
fileName = './' + fileName
52+
}
53+
54+
message =
55+
chalk.cyan(fileName) +
56+
':' +
57+
chalk.yellow(line.toString()) +
58+
':' +
59+
chalk.yellow(character.toString()) +
60+
'\n' +
61+
message
62+
63+
message +=
64+
'\n' +
65+
codeFrameColumns(
66+
diagnostic.file.getFullText(diagnostic.file.getSourceFile()),
67+
{
68+
start: { line: line, column: character },
69+
},
70+
{ forceColor: true }
71+
)
72+
}
73+
74+
return message
75+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
DiagnosticCategory,
3+
getFormattedDiagnostic,
4+
} from './diagnosticFormatter'
5+
import { getTypeScriptConfiguration } from './getTypeScriptConfiguration'
6+
import { TypeScriptCompileError } from './TypeScriptCompileError'
7+
import { getRequiredConfiguration } from './writeConfigurationDefaults'
8+
9+
export interface TypeCheckResult {
10+
hasWarnings: boolean
11+
warnings?: string[]
12+
}
13+
14+
export async function runTypeCheck(
15+
ts: typeof import('typescript'),
16+
baseDir: string,
17+
tsConfigPath: string
18+
): Promise<TypeCheckResult> {
19+
const effectiveConfiguration = await getTypeScriptConfiguration(
20+
ts,
21+
tsConfigPath
22+
)
23+
24+
if (effectiveConfiguration.fileNames.length < 1) {
25+
return { hasWarnings: false }
26+
}
27+
const requiredConfig = getRequiredConfiguration(ts)
28+
29+
const program = ts.createProgram(effectiveConfiguration.fileNames, {
30+
...effectiveConfiguration.options,
31+
...requiredConfig,
32+
noEmit: true,
33+
})
34+
const result = program.emit()
35+
36+
const regexIgnoredFile = /[\\/]__(?:tests|mocks)__[\\/]|(?:spec|test)\.[^\\/]+$/
37+
const allDiagnostics = ts
38+
.getPreEmitDiagnostics(program)
39+
.concat(result.diagnostics)
40+
.filter((d) => !(d.file && regexIgnoredFile.test(d.file.fileName)))
41+
42+
const firstError =
43+
allDiagnostics.find(
44+
(d) => d.category === DiagnosticCategory.Error && Boolean(d.file)
45+
) ?? allDiagnostics.find((d) => d.category === DiagnosticCategory.Error)
46+
47+
if (firstError) {
48+
throw new TypeScriptCompileError(
49+
await getFormattedDiagnostic(ts, baseDir, firstError)
50+
)
51+
}
52+
53+
const warnings = await Promise.all(
54+
allDiagnostics
55+
.filter((d) => d.category === DiagnosticCategory.Warning)
56+
.map((d) => getFormattedDiagnostic(ts, baseDir, d))
57+
)
58+
return { hasWarnings: true, warnings }
59+
}

packages/next/lib/typescript/writeConfigurationDefaults.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,23 @@ function getDesiredCompilerOptions(
5656
return o
5757
}
5858

59+
export function getRequiredConfiguration(
60+
ts: typeof import('typescript')
61+
): Partial<import('typescript').CompilerOptions> {
62+
const res: Partial<import('typescript').CompilerOptions> = {}
63+
64+
const desiredCompilerOptions = getDesiredCompilerOptions(ts)
65+
for (const optionKey of Object.keys(desiredCompilerOptions)) {
66+
const ev = desiredCompilerOptions[optionKey]
67+
if (!('value' in ev)) {
68+
continue
69+
}
70+
res[optionKey] = ev.parsedValue ?? ev.value
71+
}
72+
73+
return res
74+
}
75+
5976
export async function writeConfigurationDefaults(
6077
ts: typeof import('typescript'),
6178
tsConfigPath: string,

packages/next/lib/verifyTypeScriptSetup.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
1+
import chalk from 'next/dist/compiled/chalk'
12
import path from 'path'
23
import { FatalTypeScriptError } from './typescript/FatalTypeScriptError'
34
import { getTypeScriptIntent } from './typescript/getTypeScriptIntent'
45
import {
56
hasNecessaryDependencies,
67
NecessaryDependencies,
78
} from './typescript/hasNecessaryDependencies'
9+
import { runTypeCheck, TypeCheckResult } from './typescript/runTypeCheck'
10+
import { TypeScriptCompileError } from './typescript/TypeScriptCompileError'
811
import { writeAppTypeDeclarations } from './typescript/writeAppTypeDeclarations'
912
import { writeConfigurationDefaults } from './typescript/writeConfigurationDefaults'
1013

1114
export async function verifyTypeScriptSetup(
1215
dir: string,
13-
pagesDir: string
14-
): Promise<void> {
16+
pagesDir: string,
17+
typeCheckPreflight: boolean
18+
): Promise<TypeCheckResult | boolean> {
1519
const tsConfigPath = path.join(dir, 'tsconfig.json')
1620

1721
try {
1822
// Check if the project uses TypeScript:
1923
const intent = await getTypeScriptIntent(dir, pagesDir)
2024
if (!intent) {
21-
return
25+
return false
2226
}
2327
const firstTimeSetup = intent.firstTimeSetup
2428

@@ -35,9 +39,19 @@ export async function verifyTypeScriptSetup(
3539
// Write out the necessary `next-env.d.ts` file to correctly register
3640
// Next.js' types:
3741
await writeAppTypeDeclarations(dir)
42+
43+
if (typeCheckPreflight) {
44+
// Verify the project passes type-checking before we go to webpack phase:
45+
return await runTypeCheck(ts, dir, tsConfigPath)
46+
}
47+
return true
3848
} catch (err) {
39-
// This is a special error that should not show its stack trace:
40-
if (err instanceof FatalTypeScriptError) {
49+
// These are special errors that should not show a stack trace:
50+
if (err instanceof TypeScriptCompileError) {
51+
console.error(chalk.red('Failed to compile.\n'))
52+
console.error(err.message)
53+
process.exit(1)
54+
} else if (err instanceof FatalTypeScriptError) {
4155
console.error(err.message)
4256
process.exit(1)
4357
}

packages/next/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@
8686
"chokidar": "2.1.8",
8787
"css-loader": "3.5.3",
8888
"find-cache-dir": "3.3.1",
89-
"fork-ts-checker-webpack-plugin": "3.1.1",
9089
"jest-worker": "24.9.0",
9190
"loader-utils": "2.0.0",
9291
"mini-css-extract-plugin": "0.8.0",

packages/next/server/next-dev-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ export default class DevServer extends Server {
252252
}
253253

254254
async prepare(): Promise<void> {
255-
await verifyTypeScriptSetup(this.dir, this.pagesDir!)
255+
await verifyTypeScriptSetup(this.dir, this.pagesDir!, false)
256256
await this.loadCustomRoutes()
257257

258258
if (this.customRoutes) {

packages/next/types/misc.d.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -249,18 +249,8 @@ declare module 'autodll-webpack-plugin' {
249249

250250
declare module 'pnp-webpack-plugin' {
251251
import webpack from 'webpack'
252-
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'
253252

254-
class PnpWebpackPlugin extends webpack.Plugin {
255-
static forkTsCheckerOptions: <
256-
T extends Partial<ForkTsCheckerWebpackPlugin.Options>
257-
>(
258-
settings: T
259-
) => T & {
260-
resolveModuleNameModule?: string
261-
resolveTypeReferenceDirectiveModule?: string
262-
}
263-
}
253+
class PnpWebpackPlugin extends webpack.Plugin {}
264254

265255
export = PnpWebpackPlugin
266256
}

0 commit comments

Comments
 (0)