fix(js): infer dependency between typecheck and build tasks and more granular outputs for typecheck (#30549)

## Current Behavior

There is no dependency between the inferred `typecheck` and `build`
tasks. Depending on their run order, this can result in duplicated
processing (type-checking, `.d.ts` generation). Given there's no
explicit dependency, the order would be non-deterministic.

Additionally, when `outDir` is set in the tsconfig files, it's used
as-is in the currently inferred outputs for `typecheck`. This can result
in extra files being cached for the task.

## Expected Behavior

For optimum performance, the inferred `typecheck` task should depend on
the `build` task. The `typecheck` task's outputs should be more granular
so that only the relevant files (declaration files and declaration map
files if enabled) are cached.

### Explanation

Consider a typical setup with specific tsconfig file for files with
different concerns:

- tsconfig.lib.json: TS configuration for the library runtime files
- tsconfig.spec.json: TS configuration for the unit test files
- tsconfig.json: TS solution configuration, a solution file that
references the specific config files above

When running `tsc -b tsconfig.lib.json --verbose` (build), we can see
how the `tsconfig.lib.json` TS project is built:

```bash
Projects in this build:
    * tsconfig.lib.json

Project 'tsconfig.lib.json' is out of date because output file 'dist/tsconfig.lib.tsbuildinfo' does not exist

Building project '<workspace root>/packages/pkg1/tsconfig.lib.json'...
```

After that, if we run `tsc -b tsconfig.json --emitDeclarationOnly
--verbose` (typecheck), we'll see how the `tsc` output for
`tsconfig.lib.json` is reused:

```bash
Projects in this build: 
    * tsconfig.lib.json
    * tsconfig.spec.json
    * tsconfig.json

Project 'tsconfig.lib.json' is up to date because newest input 'src/lib/file.ts' is older than output 'dist/tsconfig.lib.tsbuildinfo'

Project 'tsconfig.spec.json' is out of date because output file 'out-tsc/jest/tsconfig.spec.tsbuildinfo' does not exist

Building project '<workspace root>/packages/pkg1/tsconfig.spec.json'...
```

The relevant bit above is `Project 'tsconfig.lib.json' is up to date
because newest input 'src/lib/file.ts' is older than output
'dist/tsconfig.lib.tsbuildinfo'`. Because the initial `build` task
already typechecks and produces `.d.ts` files for the
`tsconfig.lib.json`, when the `typecheck` task runs, `tsc` identifies
that the outputs for that config files were already produced and can be
reused.

If we were to run the tasks in the inverse order, the results would be
different:

```bash
> npx tsc -b tsconfig.json --emitDeclarationOnly --verbose
Projects in this build: 
    * tsconfig.lib.json
    * tsconfig.spec.json
    * tsconfig.json

Project 'tsconfig.lib.json' is out of date because output file 'dist/tsconfig.lib.tsbuildinfo' does not exist

Building project '<workspace root>/packages/pkg1/tsconfig.lib.json'...

Project 'tsconfig.spec.json' is out of date because output file 'out-tsc/jest/tsconfig.spec.tsbuildinfo' does not exist

Building project '<workspace root>/packages/pkg1/tsconfig.spec.json'...

> npx tsc -b tsconfig.lib.json --verbose
Projects in this build: 
    * tsconfig.lib.json

Project 'tsconfig.lib.json' is out of date because buildinfo file 'dist/tsconfig.lib.tsbuildinfo' indicates there is change in compilerOptions

Building project '<workspace root>/packages/pkg1/tsconfig.lib.json'...
```

Note how when the `build` task is run, `tsc` identifies that there was a
change in `compilerOptions` (`--emitDeclarationOnly`) and it requires
building the project. This is because the `typecheck` task only
generates declaration files and the `build` task must also emit the
transpiled `.js` files.

### Benchmark

Running those two different flows in a simple (non-Nx) project with a TS
configuration structure like the one mentioned above and with 5000 TS
files split in half for runtime and test files yields the following:

```bash
hyperfine -r 5 -p "rm -rf dist out-tsc" \
-n "build => typecheck" "npx tsc -b tsconfig.lib.json && npx tsc -b --emitDeclarationOnly" \
-n "typecheck => build" "npx tsc -b tsconfig.json --emitDeclarationOnly && npx tsc -b tsconfig.lib.json"
Benchmark 1: build => typecheck
  Time (mean ± σ):      6.832 s ±  0.094 s    [User: 11.361 s, System: 1.060 s]
  Range (min … max):    6.734 s …  6.985 s    5 runs
 
Benchmark 2: typecheck => build
  Time (mean ± σ):      8.789 s ±  0.015 s    [User: 14.817 s, System: 1.267 s]
  Range (min … max):    8.771 s …  8.812 s    5 runs
 
Summary
  build => typecheck ran
    1.29 ± 0.02 times faster than typecheck => build
```

## Related Issue(s)

Fixes #
This commit is contained in:
Leosvel Pérez Espinosa 2025-03-31 19:05:52 +02:00 committed by GitHub
parent e29f8f0d46
commit 5974851c24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 111 additions and 38 deletions

View File

@ -115,36 +115,36 @@ ${content}`
// check build // check build
expect(runCLI(`build ${esbuildParentLib}`)).toContain( expect(runCLI(`build ${esbuildParentLib}`)).toContain(
`Successfully ran target build for project @proj/${esbuildParentLib} and 5 tasks it depends on` `Successfully ran target build for project @proj/${esbuildParentLib}`
); );
expect(runCLI(`build ${rollupParentLib}`)).toContain( expect(runCLI(`build ${rollupParentLib}`)).toContain(
`Successfully ran target build for project @proj/${rollupParentLib} and 5 tasks it depends on` `Successfully ran target build for project @proj/${rollupParentLib}`
); );
expect(runCLI(`build ${swcParentLib}`)).toContain( expect(runCLI(`build ${swcParentLib}`)).toContain(
`Successfully ran target build for project @proj/${swcParentLib} and 5 tasks it depends on` `Successfully ran target build for project @proj/${swcParentLib}`
); );
expect(runCLI(`build ${tscParentLib}`)).toContain( expect(runCLI(`build ${tscParentLib}`)).toContain(
`Successfully ran target build for project @proj/${tscParentLib} and 5 tasks it depends on` `Successfully ran target build for project @proj/${tscParentLib}`
); );
expect(runCLI(`build ${viteParentLib}`)).toContain( expect(runCLI(`build ${viteParentLib}`)).toContain(
`Successfully ran target build for project @proj/${viteParentLib} and 5 tasks it depends on` `Successfully ran target build for project @proj/${viteParentLib}`
); );
// check typecheck // check typecheck
expect(runCLI(`typecheck ${esbuildParentLib}`)).toContain( expect(runCLI(`typecheck ${esbuildParentLib}`)).toContain(
`Successfully ran target typecheck for project @proj/${esbuildParentLib} and 5 tasks it depends on` `Successfully ran target typecheck for project @proj/${esbuildParentLib}`
); );
expect(runCLI(`typecheck ${rollupParentLib}`)).toContain( expect(runCLI(`typecheck ${rollupParentLib}`)).toContain(
`Successfully ran target typecheck for project @proj/${rollupParentLib} and 5 tasks it depends on` `Successfully ran target typecheck for project @proj/${rollupParentLib}`
); );
expect(runCLI(`typecheck ${swcParentLib}`)).toContain( expect(runCLI(`typecheck ${swcParentLib}`)).toContain(
`Successfully ran target typecheck for project @proj/${swcParentLib} and 5 tasks it depends on` `Successfully ran target typecheck for project @proj/${swcParentLib}`
); );
expect(runCLI(`typecheck ${tscParentLib}`)).toContain( expect(runCLI(`typecheck ${tscParentLib}`)).toContain(
`Successfully ran target typecheck for project @proj/${tscParentLib} and 5 tasks it depends on` `Successfully ran target typecheck for project @proj/${tscParentLib}`
); );
expect(runCLI(`typecheck ${viteParentLib}`)).toContain( expect(runCLI(`typecheck ${viteParentLib}`)).toContain(
`Successfully ran target typecheck for project @proj/${viteParentLib} and 5 tasks it depends on` `Successfully ran target typecheck for project @proj/${viteParentLib}`
); );
// check lint // check lint

View File

@ -101,12 +101,12 @@ ${content}`
// check build // check build
expect(runCLI(`build ${reactApp}`)).toContain( expect(runCLI(`build ${reactApp}`)).toContain(
`Successfully ran target build for project @proj/${reactApp} and 5 tasks it depends on` `Successfully ran target build for project @proj/${reactApp}`
); );
// check typecheck // check typecheck
expect(runCLI(`typecheck ${reactApp}`)).toContain( expect(runCLI(`typecheck ${reactApp}`)).toContain(
`Successfully ran target typecheck for project @proj/${reactApp} and 6 tasks it depends on` `Successfully ran target typecheck for project @proj/${reactApp}`
); );
}, 300_000); }, 300_000);
}); });

View File

@ -782,7 +782,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/dist/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -853,7 +854,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/dist/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -928,7 +930,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/dist/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -1003,7 +1006,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/dist/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -1083,7 +1087,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/dist/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -1158,7 +1163,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/dist/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -1240,7 +1246,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/dist/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -1348,8 +1355,10 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/cypress/dist", "{projectRoot}/dist/tsconfig.tsbuildinfo",
"{projectRoot}/cypress/dist/**/*.d.ts",
"{projectRoot}/cypress/dist/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -1395,7 +1404,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib/nested-project", "cwd": "libs/my-lib/nested-project",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/dist/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -1492,7 +1502,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/dist/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -1882,7 +1893,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{workspaceRoot}/dist/libs/my-lib", "{workspaceRoot}/dist/libs/my-lib/**/*.d.ts",
"{workspaceRoot}/dist/libs/my-lib/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -1964,6 +1976,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cache": true, "cache": true,
"command": "tsc --build --emitDeclarationOnly", "command": "tsc --build --emitDeclarationOnly",
"dependsOn": [ "dependsOn": [
"build",
"^typecheck", "^typecheck",
], ],
"inputs": [ "inputs": [
@ -1993,7 +2006,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/out-tsc/my-lib", "{projectRoot}/out-tsc/my-lib/**/*.d.ts",
"{projectRoot}/out-tsc/*.tsbuildinfo", "{projectRoot}/out-tsc/*.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
@ -2171,8 +2184,10 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"{workspaceRoot}/dist/libs/my-lib/lib.d.ts", "{workspaceRoot}/dist/libs/my-lib/lib.d.ts",
"{workspaceRoot}/dist/libs/my-lib/lib.d.ts.map", "{workspaceRoot}/dist/libs/my-lib/lib.d.ts.map",
"{workspaceRoot}/dist/libs/my-lib/lib.tsbuildinfo", "{workspaceRoot}/dist/libs/my-lib/lib.tsbuildinfo",
"{workspaceRoot}/dist/out-tsc/libs/my-lib/specs", "{workspaceRoot}/dist/out-tsc/libs/my-lib/specs/**/*.d.ts",
"{workspaceRoot}/dist/out-tsc/libs/my-lib/cypress", "{workspaceRoot}/dist/out-tsc/libs/my-lib/specs/tsconfig.tsbuildinfo",
"{workspaceRoot}/dist/out-tsc/libs/my-lib/cypress/**/*.d.ts",
"{workspaceRoot}/dist/out-tsc/libs/my-lib/cypress/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -2216,7 +2231,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib/nested-project", "cwd": "libs/my-lib/nested-project",
}, },
"outputs": [ "outputs": [
"{workspaceRoot}/dist/out-tsc/libs/my-lib/nested-project", "{workspaceRoot}/dist/out-tsc/libs/my-lib/nested-project/**/*.d.ts",
"{workspaceRoot}/dist/out-tsc/libs/my-lib/nested-project/tsconfig.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",
@ -2424,7 +2440,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/my-lib.tsbuildinfo", "{projectRoot}/my-lib.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
@ -2490,7 +2506,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"cwd": "libs/my-lib", "cwd": "libs/my-lib",
}, },
"outputs": [ "outputs": [
"{projectRoot}/dist", "{projectRoot}/dist/**/*.d.ts",
"{projectRoot}/dist/my-lib.tsbuildinfo",
], ],
"syncGenerators": [ "syncGenerators": [
"@nx/js:typescript-sync", "@nx/js:typescript-sync",

View File

@ -322,7 +322,7 @@ async function getConfigFileHash(
...(packageJson ? [hashObject(packageJson)] : []), ...(packageJson ? [hashObject(packageJson)] : []),
// change this to bust the cache when making changes that would yield // change this to bust the cache when making changes that would yield
// different results for the same hash // different results for the same hash
hashObject({ bust: 1 }), hashObject({ bust: 2 }),
]); ]);
} }
@ -415,8 +415,30 @@ function buildTscTargets(
command = `echo "The 'typecheck' target is disabled because one or more project references set 'noEmit: true' in their tsconfig. Remove this property to resolve this issue."`; command = `echo "The 'typecheck' target is disabled because one or more project references set 'noEmit: true' in their tsconfig. Remove this property to resolve this issue."`;
} }
const dependsOn: string[] = [`^${targetName}`];
if (options.build && targets[options.build.targetName]) {
// we already processed and have a build target
dependsOn.unshift(options.build.targetName);
} else if (options.build) {
// check if the project will have a build target
const buildConfigPath = joinPathFragments(
projectRoot,
options.build.configName
);
if (
context.configFiles.some((f) => f === buildConfigPath) &&
isValidPackageJsonBuildConfig(
retrieveTsConfigFromCache(buildConfigPath, context.workspaceRoot),
context.workspaceRoot,
projectRoot
)
) {
dependsOn.unshift(options.build.targetName);
}
}
targets[targetName] = { targets[targetName] = {
dependsOn: [`^${targetName}`], dependsOn,
command, command,
options: { cwd: projectRoot }, options: { cwd: projectRoot },
cache: true, cache: true,
@ -433,7 +455,8 @@ function buildTscTargets(
tsConfig, tsConfig,
internalProjectReferences, internalProjectReferences,
context.workspaceRoot, context.workspaceRoot,
projectRoot projectRoot,
/* emitDeclarationOnly */ true
), ),
syncGenerators: ['@nx/js:typescript-sync'], syncGenerators: ['@nx/js:typescript-sync'],
metadata: { metadata: {
@ -483,7 +506,9 @@ function buildTscTargets(
tsConfig, tsConfig,
internalProjectReferences, internalProjectReferences,
context.workspaceRoot, context.workspaceRoot,
projectRoot projectRoot,
// should be false for build target, but providing it just in case is set to true
tsConfig.options.emitDeclarationOnly
), ),
syncGenerators: ['@nx/js:typescript-sync'], syncGenerators: ['@nx/js:typescript-sync'],
metadata: { metadata: {
@ -685,7 +710,8 @@ function getOutputs(
tsConfig: ParsedTsconfigData, tsConfig: ParsedTsconfigData,
internalProjectReferences: Record<string, ParsedTsconfigData>, internalProjectReferences: Record<string, ParsedTsconfigData>,
workspaceRoot: string, workspaceRoot: string,
projectRoot: string projectRoot: string,
emitDeclarationOnly: boolean
): string[] { ): string[] {
const outputs = new Set<string>(); const outputs = new Set<string>();
@ -738,12 +764,32 @@ function getOutputs(
) )
); );
} else if (config.options.outDir) { } else if (config.options.outDir) {
outputs.add( if (emitDeclarationOnly) {
pathToInputOrOutput(config.options.outDir, workspaceRoot, projectRoot) outputs.add(
); pathToInputOrOutput(
joinPathFragments(config.options.outDir, '**/*.d.ts'),
workspaceRoot,
projectRoot
)
);
if (tsConfig.options.declarationMap) {
outputs.add(
pathToInputOrOutput(
joinPathFragments(config.options.outDir, '**/*.d.ts.map'),
workspaceRoot,
projectRoot
)
);
}
} else {
outputs.add(
pathToInputOrOutput(config.options.outDir, workspaceRoot, projectRoot)
);
}
if (config.options.tsBuildInfoFile) { if (config.options.tsBuildInfoFile) {
if ( if (
emitDeclarationOnly ||
!normalize(config.options.tsBuildInfoFile).startsWith( !normalize(config.options.tsBuildInfoFile).startsWith(
`${normalize(config.options.outDir)}${sep}` `${normalize(config.options.outDir)}${sep}`
) )
@ -774,6 +820,16 @@ function getOutputs(
projectRoot projectRoot
) )
); );
} else if (emitDeclarationOnly) {
// https://www.typescriptlang.org/tsconfig#tsBuildInfoFile
const name = basename(configFilePath, '.json');
outputs.add(
pathToInputOrOutput(
joinPathFragments(config.options.outDir, `${name}.tsbuildinfo`),
workspaceRoot,
projectRoot
)
);
} }
} else if ( } else if (
config.raw?.include?.length || config.raw?.include?.length ||