Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve type checking #41427

Merged
merged 24 commits into from Oct 19, 2022
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
180 changes: 99 additions & 81 deletions packages/next/build/index.ts
Expand Up @@ -172,7 +172,8 @@ function verifyTypeScriptSetup(
disableStaticImages: boolean,
cacheDir: string | undefined,
numWorkers: number | undefined,
enableWorkerThreads: boolean | undefined
enableWorkerThreads: boolean | undefined,
isAppDirEnabled: boolean
) {
const typeCheckWorker = new JestWorker(
require.resolve('../lib/verifyTypeScriptSetup'),
Expand All @@ -196,6 +197,7 @@ function verifyTypeScriptSetup(
tsconfigPath,
disableStaticImages,
cacheDir,
isAppDirEnabled,
})
.then((result) => {
typeCheckWorker.end()
Expand Down Expand Up @@ -326,100 +328,111 @@ export default async function build(
telemetry.record(events)
)

const ignoreTypeScriptErrors = Boolean(
config.typescript.ignoreBuildErrors
)

const ignoreESLint = Boolean(config.eslint.ignoreDuringBuilds)
const eslintCacheDir = path.join(cacheDir, 'eslint/')
const shouldLint = !ignoreESLint && runLint

if (ignoreTypeScriptErrors) {
Log.info('Skipping validation of types')
}
if (runLint && ignoreESLint) {
// only print log when build requre lint while ignoreESLint is enabled
Log.info('Skipping linting')
}
const startTypeChecking = async () => {
const ignoreTypeScriptErrors = Boolean(
config.typescript.ignoreBuildErrors
)

let typeCheckingAndLintingSpinnerPrefixText: string | undefined
let typeCheckingAndLintingSpinner:
| ReturnType<typeof createSpinner>
| undefined

if (!ignoreTypeScriptErrors && shouldLint) {
typeCheckingAndLintingSpinnerPrefixText =
'Linting and checking validity of types'
} else if (!ignoreTypeScriptErrors) {
typeCheckingAndLintingSpinnerPrefixText = 'Checking validity of types'
} else if (shouldLint) {
typeCheckingAndLintingSpinnerPrefixText = 'Linting'
}
const eslintCacheDir = path.join(cacheDir, 'eslint/')

// we will not create a spinner if both ignoreTypeScriptErrors and ignoreESLint are
// enabled, but we will still verifying project's tsconfig and dependencies.
if (typeCheckingAndLintingSpinnerPrefixText) {
typeCheckingAndLintingSpinner = createSpinner({
prefixText: `${Log.prefixes.info} ${typeCheckingAndLintingSpinnerPrefixText}`,
})
}
if (ignoreTypeScriptErrors) {
Log.info('Skipping validation of types')
}
if (runLint && ignoreESLint) {
// only print log when build requre lint while ignoreESLint is enabled
Log.info('Skipping linting')
}

const typeCheckStart = process.hrtime()
let typeCheckingAndLintingSpinnerPrefixText: string | undefined
let typeCheckingAndLintingSpinner:
| ReturnType<typeof createSpinner>
| undefined

if (!ignoreTypeScriptErrors && shouldLint) {
typeCheckingAndLintingSpinnerPrefixText =
'Linting and checking validity of types'
} else if (!ignoreTypeScriptErrors) {
typeCheckingAndLintingSpinnerPrefixText = 'Checking validity of types'
} else if (shouldLint) {
typeCheckingAndLintingSpinnerPrefixText = 'Linting'
}

try {
const [[verifyResult, typeCheckEnd]] = await Promise.all([
nextBuildSpan.traceChild('verify-typescript-setup').traceAsyncFn(() =>
verifyTypeScriptSetup(
dir,
[pagesDir, appDir].filter(Boolean) as string[],
!ignoreTypeScriptErrors,
config.typescript.tsconfigPath,
config.images.disableStaticImages,
cacheDir,
config.experimental.cpus,
config.experimental.workerThreads
).then((resolved) => {
const checkEnd = process.hrtime(typeCheckStart)
return [resolved, checkEnd] as const
})
),
shouldLint &&
// we will not create a spinner if both ignoreTypeScriptErrors and ignoreESLint are
// enabled, but we will still verifying project's tsconfig and dependencies.
if (typeCheckingAndLintingSpinnerPrefixText) {
typeCheckingAndLintingSpinner = createSpinner({
prefixText: `${Log.prefixes.info} ${typeCheckingAndLintingSpinnerPrefixText}`,
})
}

const typeCheckStart = process.hrtime()

try {
const [[verifyResult, typeCheckEnd]] = await Promise.all([
nextBuildSpan
.traceChild('verify-and-lint')
.traceAsyncFn(async () => {
await verifyAndLint(
.traceChild('verify-typescript-setup')
.traceAsyncFn(() =>
verifyTypeScriptSetup(
dir,
eslintCacheDir,
config.eslint?.dirs,
[pagesDir, appDir].filter(Boolean) as string[],
!ignoreTypeScriptErrors,
config.typescript.tsconfigPath,
config.images.disableStaticImages,
cacheDir,
config.experimental.cpus,
config.experimental.workerThreads,
telemetry,
isAppDirEnabled && !!appDir
)
}),
])
typeCheckingAndLintingSpinner?.stopAndPersist()

if (!ignoreTypeScriptErrors && verifyResult) {
telemetry.record(
eventTypeCheckCompleted({
durationInSeconds: typeCheckEnd[0],
typescriptVersion: verifyResult.version,
inputFilesCount: verifyResult.result?.inputFilesCount,
totalFilesCount: verifyResult.result?.totalFilesCount,
incremental: verifyResult.result?.incremental,
})
)
}
} catch (err) {
// prevent showing jest-worker internal error as it
// isn't helpful for users and clutters output
if (isError(err) && err.message === 'Call retries were exceeded') {
process.exit(1)
isAppDirEnabled
).then((resolved) => {
const checkEnd = process.hrtime(typeCheckStart)
return [resolved, checkEnd] as const
})
),
shouldLint &&
nextBuildSpan
.traceChild('verify-and-lint')
.traceAsyncFn(async () => {
await verifyAndLint(
dir,
eslintCacheDir,
config.eslint?.dirs,
config.experimental.cpus,
config.experimental.workerThreads,
telemetry,
isAppDirEnabled && !!appDir
)
}),
])
typeCheckingAndLintingSpinner?.stopAndPersist()

if (!ignoreTypeScriptErrors && verifyResult) {
telemetry.record(
eventTypeCheckCompleted({
durationInSeconds: typeCheckEnd[0],
typescriptVersion: verifyResult.version,
inputFilesCount: verifyResult.result?.inputFilesCount,
totalFilesCount: verifyResult.result?.totalFilesCount,
incremental: verifyResult.result?.incremental,
})
)
}
} catch (err) {
// prevent showing jest-worker internal error as it
// isn't helpful for users and clutters output
if (isError(err) && err.message === 'Call retries were exceeded') {
process.exit(1)
}
throw err
}
throw err
}

// For app directory, we run type checking after build. That's because
// we dynamically generate types for each layout and page in the app
// directory.
if (!appDir) await startTypeChecking()

const buildLintEvent: EventBuildFeatureUsage = {
featureName: 'build-lint',
invocationCount: shouldLint ? 1 : 0,
Expand Down Expand Up @@ -1071,6 +1084,11 @@ export default async function build(
}
}

// For app directory, we run type checking after build.
if (appDir) {
await startTypeChecking()
}

const postCompileSpinner = createSpinner({
prefixText: `${Log.prefixes.info} Collecting page data`,
})
Expand Down
4 changes: 4 additions & 0 deletions packages/next/build/webpack-config.ts
Expand Up @@ -49,6 +49,7 @@ import { regexLikeCss } from './webpack/config/blocks/css'
import { CopyFilePlugin } from './webpack/plugins/copy-file-plugin'
import { FlightManifestPlugin } from './webpack/plugins/flight-manifest-plugin'
import { FlightClientEntryPlugin } from './webpack/plugins/flight-client-entry-plugin'
import { FlightTypesPlugin } from './webpack/plugins/flight-types-plugin'
import type {
Feature,
SWC_TARGET_TRIPLE,
Expand Down Expand Up @@ -1945,6 +1946,9 @@ export default async function getBaseWebpackConfig(
dev,
isEdgeServer,
})),
hasAppDir &&
!isClient &&
new FlightTypesPlugin({ appDir, dev, isEdgeServer }),
!dev &&
isClient &&
!!config.experimental.sri?.algorithm &&
Expand Down
147 changes: 147 additions & 0 deletions packages/next/build/webpack/plugins/flight-types-plugin.ts
@@ -0,0 +1,147 @@
import path from 'path'

import { webpack, sources } from 'next/dist/compiled/webpack/webpack'
import { WEBPACK_LAYERS } from '../../../lib/constants'

const PLUGIN_NAME = 'FlightTypesPlugin'

interface Options {
appDir: string
dev: boolean
isEdgeServer: boolean
}

function createTypeGuardFile(
fullPath: string,
relativePath: string,
options: {
type: 'layout' | 'page'
}
) {
return `// File: ${fullPath}
import * as entry from '${relativePath}'
type TEntry = typeof entry

check<IEntry, TEntry>(entry)

interface IEntry {
${
options.type === 'layout'
? `default: (props: { children: React.ReactNode; params?: any }) => React.ReactNode | null`
: `default: (props: { params?: any }) => React.ReactNode | null`
}
generateStaticParams?: (params?:any) => Promise<any[]>
config?: {
// TODO: remove revalidate here
revalidate?: number | boolean
${options.type === 'page' ? 'runtime?: string' : ''}
}
revalidate?: RevalidateRange<TEntry> | false
dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
dynamicParams?: boolean
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
preferredRegion?: 'auto' | 'home' | 'edge'
}

// =============
// Utility types
type RevalidateRange<T> = T extends { revalidate: any } ? NonNegative<T['revalidate']> : never
type Impossible<K extends keyof any> = { [P in K]: never }
function check<Base, T extends Base>(_mod: T & Impossible<Exclude<keyof T, keyof Base>>): void {}

// https://github.com/sindresorhus/type-fest
type Numeric = number | bigint
type Zero = 0 | 0n
type Negative<T extends Numeric> = T extends Zero ? never : \`\${T}\` extends \`-\${string}\` ? T : never
type NonNegative<T extends Numeric> = T extends Zero ? T : Negative<T> extends never ? T : '__invalid_negative_number__'
`
}

export class FlightTypesPlugin {
appDir: string
dev: boolean
isEdgeServer: boolean

constructor(options: Options) {
this.appDir = options.appDir
this.dev = options.dev
this.isEdgeServer = options.isEdgeServer
}

apply(compiler: webpack.Compiler) {
const assetPrefix = this.dev
? '..'
: this.isEdgeServer
? '..'
: path.join('..', '..')

const handleModule = (mod: webpack.NormalModule, assets: any) => {
if (mod.layer !== WEBPACK_LAYERS.server) return
if (!mod.resource) return
if (!mod.resource.startsWith(this.appDir + path.sep)) return
if (!/\.(js|jsx|ts|tsx|mjs)$/.test(mod.resource)) return

const IS_LAYOUT = /[/\\]layout\.[^.]+$/.test(mod.resource)
shuding marked this conversation as resolved.
Show resolved Hide resolved
const IS_PAGE = !IS_LAYOUT && /[/\\]page\.[^.]+$/.test(mod.resource)
const relativePath = path.relative(this.appDir, mod.resource)

// const RSC = mod.buildInfo.rsc
shuding marked this conversation as resolved.
Show resolved Hide resolved

const typePath = path.join(
'types',
'app',
relativePath.replace(/\.(js|jsx|ts|tsx|mjs)$/, '.ts')
)
const relativeImportPath = path.join(
path.relative(typePath, ''),
'app',
relativePath.replace(/\.(js|jsx|ts|tsx|mjs)$/, '')
)
const assetPath = path.join(assetPrefix, typePath)
shuding marked this conversation as resolved.
Show resolved Hide resolved

if (IS_LAYOUT) {
assets[assetPath] = new sources.RawSource(
Copy link
Member

Choose a reason for hiding this comment

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

This is a fresh Source instance on every compilation, so it will try to write all of them on each recompilation. Use a cached instance.

Copy link
Member

Choose a reason for hiding this comment

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

But that doesn't really matter since the plugin is only applied on production builds. Shouldn't page type be tested in dev too? I guess here it would matter the most?

createTypeGuardFile(mod.resource, relativeImportPath, {
type: 'layout',
})
) as unknown as webpack.sources.RawSource
} else if (IS_PAGE) {
assets[assetPath] = new sources.RawSource(
createTypeGuardFile(mod.resource, relativeImportPath, {
type: 'page',
})
) as unknown as webpack.sources.RawSource
}
}

compiler.hooks.make.tap(PLUGIN_NAME, (compilation) => {
shuding marked this conversation as resolved.
Show resolved Hide resolved
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_HASH,
},
(assets) => {
compilation.chunkGroups.forEach((chunkGroup) => {
chunkGroup.chunks.forEach((chunk: webpack.Chunk) => {
const chunkModules =
compilation.chunkGraph.getChunkModulesIterable(
chunk
// TODO: Update type so that it doesn't have to be cast.
) as Iterable<webpack.NormalModule>
for (const mod of chunkModules) {
handleModule(mod, assets)

const anyModule = mod as any
if (anyModule.modules) {
anyModule.modules.forEach((concatenatedMod: any) => {
handleModule(concatenatedMod, assets)
})
}
}
})
})
}
)
shuding marked this conversation as resolved.
Show resolved Hide resolved
})
}
}