From 5974851c24f13eed1e9ff609c6d465442b6e04fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Mon, 31 Mar 2025 19:05:52 +0200 Subject: [PATCH] fix(js): infer dependency between `typecheck` and `build` tasks and more granular outputs for `typecheck` (#30549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 '/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 '/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 '/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 '/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 '/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 # --- e2e/js/src/js-ts-solution.test.ts | 20 +++--- e2e/vite/src/vite-ts-solution.test.ts | 4 +- .../js/src/plugins/typescript/plugin.spec.ts | 53 +++++++++----- packages/js/src/plugins/typescript/plugin.ts | 72 ++++++++++++++++--- 4 files changed, 111 insertions(+), 38 deletions(-) diff --git a/e2e/js/src/js-ts-solution.test.ts b/e2e/js/src/js-ts-solution.test.ts index b7211a6c9b..ebd1283d53 100644 --- a/e2e/js/src/js-ts-solution.test.ts +++ b/e2e/js/src/js-ts-solution.test.ts @@ -115,36 +115,36 @@ ${content}` // check build 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( - `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( - `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( - `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( - `Successfully ran target build for project @proj/${viteParentLib} and 5 tasks it depends on` + `Successfully ran target build for project @proj/${viteParentLib}` ); // check typecheck 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( - `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( - `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( - `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( - `Successfully ran target typecheck for project @proj/${viteParentLib} and 5 tasks it depends on` + `Successfully ran target typecheck for project @proj/${viteParentLib}` ); // check lint diff --git a/e2e/vite/src/vite-ts-solution.test.ts b/e2e/vite/src/vite-ts-solution.test.ts index ae74cbde8d..6db41d52d2 100644 --- a/e2e/vite/src/vite-ts-solution.test.ts +++ b/e2e/vite/src/vite-ts-solution.test.ts @@ -101,12 +101,12 @@ ${content}` // check build 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 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); }); diff --git a/packages/js/src/plugins/typescript/plugin.spec.ts b/packages/js/src/plugins/typescript/plugin.spec.ts index 3a49e7d38d..91b42cb2dc 100644 --- a/packages/js/src/plugins/typescript/plugin.spec.ts +++ b/packages/js/src/plugins/typescript/plugin.spec.ts @@ -782,7 +782,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/dist", + "{projectRoot}/dist/**/*.d.ts", + "{projectRoot}/dist/tsconfig.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", @@ -853,7 +854,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/dist", + "{projectRoot}/dist/**/*.d.ts", + "{projectRoot}/dist/tsconfig.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", @@ -928,7 +930,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/dist", + "{projectRoot}/dist/**/*.d.ts", + "{projectRoot}/dist/tsconfig.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", @@ -1003,7 +1006,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/dist", + "{projectRoot}/dist/**/*.d.ts", + "{projectRoot}/dist/tsconfig.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", @@ -1083,7 +1087,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/dist", + "{projectRoot}/dist/**/*.d.ts", + "{projectRoot}/dist/tsconfig.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", @@ -1158,7 +1163,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/dist", + "{projectRoot}/dist/**/*.d.ts", + "{projectRoot}/dist/tsconfig.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", @@ -1240,7 +1246,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/dist", + "{projectRoot}/dist/**/*.d.ts", + "{projectRoot}/dist/tsconfig.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", @@ -1348,8 +1355,10 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/dist", - "{projectRoot}/cypress/dist", + "{projectRoot}/dist/**/*.d.ts", + "{projectRoot}/dist/tsconfig.tsbuildinfo", + "{projectRoot}/cypress/dist/**/*.d.ts", + "{projectRoot}/cypress/dist/tsconfig.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", @@ -1395,7 +1404,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib/nested-project", }, "outputs": [ - "{projectRoot}/dist", + "{projectRoot}/dist/**/*.d.ts", + "{projectRoot}/dist/tsconfig.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", @@ -1492,7 +1502,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/dist", + "{projectRoot}/dist/**/*.d.ts", + "{projectRoot}/dist/tsconfig.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", @@ -1882,7 +1893,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{workspaceRoot}/dist/libs/my-lib", + "{workspaceRoot}/dist/libs/my-lib/**/*.d.ts", + "{workspaceRoot}/dist/libs/my-lib/tsconfig.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", @@ -1964,6 +1976,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cache": true, "command": "tsc --build --emitDeclarationOnly", "dependsOn": [ + "build", "^typecheck", ], "inputs": [ @@ -1993,7 +2006,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/out-tsc/my-lib", + "{projectRoot}/out-tsc/my-lib/**/*.d.ts", "{projectRoot}/out-tsc/*.tsbuildinfo", ], "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.map", "{workspaceRoot}/dist/libs/my-lib/lib.tsbuildinfo", - "{workspaceRoot}/dist/out-tsc/libs/my-lib/specs", - "{workspaceRoot}/dist/out-tsc/libs/my-lib/cypress", + "{workspaceRoot}/dist/out-tsc/libs/my-lib/specs/**/*.d.ts", + "{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": [ "@nx/js:typescript-sync", @@ -2216,7 +2231,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib/nested-project", }, "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": [ "@nx/js:typescript-sync", @@ -2424,7 +2440,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/dist", + "{projectRoot}/dist/**/*.d.ts", "{projectRoot}/my-lib.tsbuildinfo", ], "syncGenerators": [ @@ -2490,7 +2506,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [ - "{projectRoot}/dist", + "{projectRoot}/dist/**/*.d.ts", + "{projectRoot}/dist/my-lib.tsbuildinfo", ], "syncGenerators": [ "@nx/js:typescript-sync", diff --git a/packages/js/src/plugins/typescript/plugin.ts b/packages/js/src/plugins/typescript/plugin.ts index c54d921a9f..5e61a7d303 100644 --- a/packages/js/src/plugins/typescript/plugin.ts +++ b/packages/js/src/plugins/typescript/plugin.ts @@ -322,7 +322,7 @@ async function getConfigFileHash( ...(packageJson ? [hashObject(packageJson)] : []), // change this to bust the cache when making changes that would yield // 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."`; } + 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] = { - dependsOn: [`^${targetName}`], + dependsOn, command, options: { cwd: projectRoot }, cache: true, @@ -433,7 +455,8 @@ function buildTscTargets( tsConfig, internalProjectReferences, context.workspaceRoot, - projectRoot + projectRoot, + /* emitDeclarationOnly */ true ), syncGenerators: ['@nx/js:typescript-sync'], metadata: { @@ -483,7 +506,9 @@ function buildTscTargets( tsConfig, internalProjectReferences, 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'], metadata: { @@ -685,7 +710,8 @@ function getOutputs( tsConfig: ParsedTsconfigData, internalProjectReferences: Record, workspaceRoot: string, - projectRoot: string + projectRoot: string, + emitDeclarationOnly: boolean ): string[] { const outputs = new Set(); @@ -738,12 +764,32 @@ function getOutputs( ) ); } else if (config.options.outDir) { - outputs.add( - pathToInputOrOutput(config.options.outDir, workspaceRoot, projectRoot) - ); + if (emitDeclarationOnly) { + 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 ( + emitDeclarationOnly || !normalize(config.options.tsBuildInfoFile).startsWith( `${normalize(config.options.outDir)}${sep}` ) @@ -774,6 +820,16 @@ function getOutputs( 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 ( config.raw?.include?.length ||