diff --git a/packages/js/src/generators/typescript-sync/typescript-sync.ts b/packages/js/src/generators/typescript-sync/typescript-sync.ts index 08ed49fb49029..1b0936ac258d8 100644 --- a/packages/js/src/generators/typescript-sync/typescript-sync.ts +++ b/packages/js/src/generators/typescript-sync/typescript-sync.ts @@ -51,6 +51,12 @@ type GeneratorOptions = { }; type NormalizedGeneratorOptions = Required; +type TsconfigInfoCaches = { + composite: Map; + content: Map; + exists: Map; + isFile: Map; +}; export async function syncGenerator(tree: Tree): Promise { // Ensure that the plugin has been wired up in nx.json @@ -71,24 +77,28 @@ export async function syncGenerator(tree: Tree): Promise { ]); } + const tsconfigInfoCaches: TsconfigInfoCaches = { + composite: new Map(), + content: new Map(), + exists: new Map(), + isFile: new Map(), + }; // Root tsconfig containing project references for the whole workspace const rootTsconfigPath = 'tsconfig.json'; - if (!tree.exists(rootTsconfigPath)) { + if (!tsconfigExists(tree, tsconfigInfoCaches, rootTsconfigPath)) { throw new SyncError('Missing root "tsconfig.json"', [ `A "tsconfig.json" file must exist in the workspace root in order to sync the project graph information to the TypeScript configuration files.`, ]); } - const rawTsconfigContentsCache = new Map(); const stringifiedRootJsonContents = readRawTsconfigContents( tree, - rawTsconfigContentsCache, + tsconfigInfoCaches, rootTsconfigPath ); const rootTsconfig = parseJson(stringifiedRootJsonContents); const projectGraph = await createProjectGraphAsync(); const projectRoots = new Set(); - const tsconfigHasCompositeEnabledCache = new Map(); const tsconfigProjectNodeValues = Object.values(projectGraph.nodes).filter( (node) => { @@ -97,18 +107,17 @@ export async function syncGenerator(tree: Tree): Promise { node.data.root, 'tsconfig.json' ); - return tsconfigExists( - tree, - rawTsconfigContentsCache, - projectTsconfigPath - ); + return tsconfigExists(tree, tsconfigInfoCaches, projectTsconfigPath); } ); const tsSysFromTree: ts.System = { ...ts.sys, + fileExists(path) { + return tsconfigExists(tree, tsconfigInfoCaches, path); + }, readFile(path) { - return readRawTsconfigContents(tree, rawTsconfigContentsCache, path); + return readRawTsconfigContents(tree, tsconfigInfoCaches, path); }, }; @@ -125,9 +134,10 @@ export async function syncGenerator(tree: Tree): Promise { const resolvedRefPath = getTsConfigPathFromReferencePath( tree, rootTsconfigPath, - ref.path + ref.path, + tsconfigInfoCaches ); - if (tsconfigExists(tree, rawTsconfigContentsCache, resolvedRefPath)) { + if (tsconfigExists(tree, tsconfigInfoCaches, resolvedRefPath)) { // we only keep the references that still exist referencesSet.add(normalizeReferencePath(ref.path)); } else { @@ -150,7 +160,7 @@ export async function syncGenerator(tree: Tree): Promise { .filter((ref) => hasCompositeEnabled( tsSysFromTree, - tsconfigHasCompositeEnabledCache, + tsconfigInfoCaches, joinPathFragments(ref, 'tsconfig.json') ) ) @@ -159,7 +169,7 @@ export async function syncGenerator(tree: Tree): Promise { })); patchTsconfigJsonReferences( tree, - rawTsconfigContentsCache, + tsconfigInfoCaches, rootTsconfigPath, updatedReferences ); @@ -192,9 +202,7 @@ export async function syncGenerator(tree: Tree): Promise { sourceProjectNode.data.root, 'tsconfig.json' ); - if ( - !tsconfigExists(tree, rawTsconfigContentsCache, sourceProjectTsconfigPath) - ) { + if (!tsconfigExists(tree, tsconfigInfoCaches, sourceProjectTsconfigPath)) { if (process.env.NX_VERBOSE_LOGGING === 'true') { logger.warn( `Skipping project "${projectName}" as there is no tsconfig.json file found in the project root "${sourceProjectNode.data.root}".` @@ -216,9 +224,7 @@ export async function syncGenerator(tree: Tree): Promise { sourceProjectNode.data.root, runtimeTsConfigFileName ); - if ( - !tsconfigExists(tree, rawTsconfigContentsCache, runtimeTsConfigPath) - ) { + if (!tsconfigExists(tree, tsconfigInfoCaches, runtimeTsConfigPath)) { continue; } @@ -227,8 +233,7 @@ export async function syncGenerator(tree: Tree): Promise { updateTsConfigReferences( tree, tsSysFromTree, - rawTsconfigContentsCache, - tsconfigHasCompositeEnabledCache, + tsconfigInfoCaches, runtimeTsConfigPath, dependencies, sourceProjectNode.data.root, @@ -243,8 +248,7 @@ export async function syncGenerator(tree: Tree): Promise { updateTsConfigReferences( tree, tsSysFromTree, - rawTsconfigContentsCache, - tsconfigHasCompositeEnabledCache, + tsconfigInfoCaches, sourceProjectTsconfigPath, dependencies, sourceProjectNode.data.root, @@ -270,16 +274,17 @@ export default syncGenerator; */ function readRawTsconfigContents( tree: Tree, - rawTsconfigContentsCache: Map, + tsconfigInfoCaches: TsconfigInfoCaches, tsconfigPath: string ): string { - if (!rawTsconfigContentsCache.has(tsconfigPath)) { - rawTsconfigContentsCache.set( + if (!tsconfigInfoCaches.content.has(tsconfigPath)) { + tsconfigInfoCaches.content.set( tsconfigPath, tree.read(tsconfigPath, 'utf-8') ); } - return rawTsconfigContentsCache.get(tsconfigPath); + + return tsconfigInfoCaches.content.get(tsconfigPath); } /** @@ -288,19 +293,20 @@ function readRawTsconfigContents( */ function tsconfigExists( tree: Tree, - rawTsconfigContentsCache: Map, + tsconfigInfoCaches: TsconfigInfoCaches, tsconfigPath: string ): boolean { - return rawTsconfigContentsCache.has(tsconfigPath) - ? true - : tree.exists(tsconfigPath); + if (!tsconfigInfoCaches.exists.has(tsconfigPath)) { + tsconfigInfoCaches.exists.set(tsconfigPath, tree.exists(tsconfigPath)); + } + + return tsconfigInfoCaches.exists.get(tsconfigPath); } function updateTsConfigReferences( tree: Tree, tsSysFromTree: ts.System, - rawTsconfigContentsCache: Map, - tsconfigHasCompositeEnabledCache: Map, + tsconfigInfoCaches: TsconfigInfoCaches, tsConfigPath: string, dependencies: ProjectGraphProjectNode[], projectRoot: string, @@ -310,7 +316,7 @@ function updateTsConfigReferences( ): boolean { const stringifiedJsonContents = readRawTsconfigContents( tree, - rawTsconfigContentsCache, + tsconfigInfoCaches, tsConfigPath ); const tsConfig = parseJson(stringifiedJsonContents); @@ -335,12 +341,13 @@ function updateTsConfigReferences( const resolvedRefPath = getTsConfigPathFromReferencePath( tree, tsConfigPath, - ref.path + ref.path, + tsconfigInfoCaches ); if ( isProjectReferenceWithinNxProject( tree, - rawTsconfigContentsCache, + tsconfigInfoCaches, resolvedRefPath, projectRoot, projectRoots @@ -362,12 +369,12 @@ function updateTsConfigReferences( dep.data.root, runtimeTsConfigFileName ); - if (tsconfigExists(tree, rawTsconfigContentsCache, runtimeTsConfigPath)) { + if (tsconfigExists(tree, tsconfigInfoCaches, runtimeTsConfigPath)) { // Check composite is true in the dependency runtime tsconfig file before proceeding if ( !hasCompositeEnabled( tsSysFromTree, - tsconfigHasCompositeEnabledCache, + tsconfigInfoCaches, runtimeTsConfigPath ) ) { @@ -386,7 +393,7 @@ function updateTsConfigReferences( if ( tsconfigExists( tree, - rawTsconfigContentsCache, + tsconfigInfoCaches, possibleRuntimeTsConfigPath ) ) { @@ -394,7 +401,7 @@ function updateTsConfigReferences( if ( !hasCompositeEnabled( tsSysFromTree, - tsconfigHasCompositeEnabledCache, + tsconfigInfoCaches, possibleRuntimeTsConfigPath ) ) { @@ -410,7 +417,7 @@ function updateTsConfigReferences( if ( !hasCompositeEnabled( tsSysFromTree, - tsconfigHasCompositeEnabledCache, + tsconfigInfoCaches, joinPathFragments(dep.data.root, 'tsconfig.json') ) ) { @@ -433,7 +440,7 @@ function updateTsConfigReferences( if (hasChanges) { patchTsconfigJsonReferences( tree, - rawTsconfigContentsCache, + tsconfigInfoCaches, tsConfigPath, references ); @@ -508,14 +515,14 @@ function normalizeReferencePath(path: string): string { function isProjectReferenceWithinNxProject( tree: Tree, - rawTsconfigContentsCache: Map, + tsconfigInfoCaches: TsconfigInfoCaches, refTsConfigPath: string, projectRoot: string, projectRoots: Set ): boolean { let currentPath = getTsConfigDirName( tree, - rawTsconfigContentsCache, + tsconfigInfoCaches, refTsConfigPath ); @@ -554,14 +561,10 @@ function isProjectReferenceIgnored( function getTsConfigDirName( tree: Tree, - rawTsconfigContentsCache: Map, + tsconfigInfoCaches: TsconfigInfoCaches, tsConfigPath: string ): string { - return ( - rawTsconfigContentsCache.has(tsConfigPath) - ? true - : tree.isFile(tsConfigPath) - ) + return tsconfigIsFile(tree, tsconfigInfoCaches, tsConfigPath) ? dirname(tsConfigPath) : normalize(tsConfigPath); } @@ -569,14 +572,15 @@ function getTsConfigDirName( function getTsConfigPathFromReferencePath( tree: Tree, ownerTsConfigPath: string, - referencePath: string + referencePath: string, + tsconfigInfoCaches: TsconfigInfoCaches ): string { const resolvedRefPath = joinPathFragments( dirname(ownerTsConfigPath), referencePath ); - return tree.isFile(resolvedRefPath) + return tsconfigIsFile(tree, tsconfigInfoCaches, resolvedRefPath) ? resolvedRefPath : joinPathFragments(resolvedRefPath, 'tsconfig.json'); } @@ -587,13 +591,13 @@ function getTsConfigPathFromReferencePath( */ function patchTsconfigJsonReferences( tree: Tree, - rawTsconfigContentsCache: Map, + tsconfigInfoCaches: TsconfigInfoCaches, tsconfigPath: string, updatedReferences: { path: string }[] ) { const stringifiedJsonContents = readRawTsconfigContents( tree, - rawTsconfigContentsCache, + tsconfigInfoCaches, tsconfigPath ); const edits = modify( @@ -609,17 +613,40 @@ function patchTsconfigJsonReferences( function hasCompositeEnabled( tsSysFromTree: ts.System, - tsconfigHasCompositeEnabledCache: Map, + tsconfigInfoCaches: TsconfigInfoCaches, tsconfigPath: string ): boolean { - if (!tsconfigHasCompositeEnabledCache.has(tsconfigPath)) { + if (!tsconfigInfoCaches.composite.has(tsconfigPath)) { const parsed = ts.parseJsonConfigFileContent( ts.readConfigFile(tsconfigPath, tsSysFromTree.readFile).config, tsSysFromTree, dirname(tsconfigPath) ); - const enabledVal = parsed.options.composite === true; - tsconfigHasCompositeEnabledCache.set(tsconfigPath, enabledVal); + tsconfigInfoCaches.composite.set( + tsconfigPath, + parsed.options.composite === true + ); + } + + return tsconfigInfoCaches.composite.get(tsconfigPath); +} + +function tsconfigIsFile( + tree: Tree, + tsconfigInfoCaches: TsconfigInfoCaches, + tsconfigPath: string +): boolean { + if (tsconfigInfoCaches.isFile.has(tsconfigPath)) { + return tsconfigInfoCaches.isFile.get(tsconfigPath); + } + + if (tsconfigInfoCaches.content.has(tsconfigPath)) { + // if it has content, it's a file + tsconfigInfoCaches.isFile.set(tsconfigPath, true); + return true; } - return tsconfigHasCompositeEnabledCache.get(tsconfigPath); + + tsconfigInfoCaches.isFile.set(tsconfigPath, tree.isFile(tsconfigPath)); + + return tsconfigInfoCaches.isFile.get(tsconfigPath); } diff --git a/packages/js/src/plugins/typescript/plugin.spec.ts b/packages/js/src/plugins/typescript/plugin.spec.ts index 0e97fb30d895c..27516ebe8fe75 100644 --- a/packages/js/src/plugins/typescript/plugin.spec.ts +++ b/packages/js/src/plugins/typescript/plugin.spec.ts @@ -1,6 +1,8 @@ -import { type CreateNodesContext } from '@nx/devkit'; +import { detectPackageManager, type CreateNodesContext } from '@nx/devkit'; import { TempFs } from '@nx/devkit/internal-testing-utils'; import { minimatch } from 'minimatch'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { getLockFileName } from 'nx/src/plugins/js/lock-file/lock-file'; import { setupWorkspaceContext } from 'nx/src/utils/workspace-context'; import { PLUGIN_NAME, createNodesV2, type TscPluginOptions } from './plugin'; @@ -25,6 +27,10 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { process.chdir(tempFs.tempDir); originalCacheProjectGraph = process.env.NX_CACHE_PROJECT_GRAPH; process.env.NX_CACHE_PROJECT_GRAPH = 'false'; + const lockFileName = getLockFileName( + detectPackageManager(context.workspaceRoot) + ); + applyFilesToTempFsAndContext(tempFs, context, { [lockFileName]: '' }); }); afterEach(() => { diff --git a/packages/js/src/plugins/typescript/plugin.ts b/packages/js/src/plugins/typescript/plugin.ts index 2ec1ba459eb4d..1fa00a7359aa8 100644 --- a/packages/js/src/plugins/typescript/plugin.ts +++ b/packages/js/src/plugins/typescript/plugin.ts @@ -16,12 +16,11 @@ import { type ProjectConfiguration, type TargetConfiguration, } from '@nx/devkit'; -import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes'; import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs'; import { minimatch } from 'minimatch'; import { existsSync, readdirSync, statSync } from 'node:fs'; import { basename, dirname, join, normalize, relative } from 'node:path'; -import { hashObject } from 'nx/src/hasher/file-hasher'; +import { hashArray, hashFile, hashObject } from 'nx/src/hasher/file-hasher'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { getLockFileName } from 'nx/src/plugins/js/lock-file/lock-file'; import { workspaceDataDirectory } from 'nx/src/utils/cache-directory'; @@ -91,10 +90,19 @@ export const createNodesV2: CreateNodesV2 = [ const cachePath = join(workspaceDataDirectory, `tsc-${optionsHash}.hash`); const targetsCache = readTargetsCache(cachePath); const normalizedOptions = normalizePluginOptions(options); + const lockFileName = getLockFileName( + detectPackageManager(context.workspaceRoot) + ); try { return await createNodesFromFiles( (configFile, options, context) => - createNodesInternal(configFile, options, context, targetsCache), + createNodesInternal( + configFile, + options, + context, + lockFileName, + targetsCache + ), configFilePaths, normalizedOptions, context @@ -112,7 +120,16 @@ export const createNodes: CreateNodes = [ '`createNodes` is deprecated. Update your plugin to utilize createNodesV2 instead. In Nx 20, this will change to the createNodesV2 API.' ); const normalizedOptions = normalizePluginOptions(options); - return createNodesInternal(configFilePath, normalizedOptions, context, {}); + const lockFileName = getLockFileName( + detectPackageManager(context.workspaceRoot) + ); + return createNodesInternal( + configFilePath, + normalizedOptions, + context, + lockFileName, + {} + ); }, ]; @@ -120,6 +137,7 @@ async function createNodesInternal( configFilePath: string, options: NormalizedPluginOptions, context: CreateNodesContext, + lockFileName: string, targetsCache: Record ): Promise { const projectRoot = dirname(configFilePath); @@ -150,13 +168,23 @@ async function createNodesInternal( return {}; } - const nodeHash = await calculateHashForCreateNodes( - projectRoot, - options, - context, - [getLockFileName(detectPackageManager(context.workspaceRoot))] + /** + * The cache key is composed by: + * - hashes of the content of the relevant files that can affect what's inferred by the plugin: + * - current config file + * - config files extended by the current config file (recursively up to the root config file) + * - lock file + * - hash of the plugin options + * - current config file path + */ + const extendedConfigFiles = getExtendedConfigFiles( + fullConfigPath, + readCachedTsConfig(fullConfigPath) ); - // The hash is calculated at the node/project level, so we add the config file path to avoid conflicts when caching + const nodeHash = hashArray([ + ...[configFilePath, ...extendedConfigFiles, lockFileName].map(hashFile), + hashObject(options), + ]); const cacheKey = `${nodeHash}_${configFilePath}`; targetsCache[cacheKey] ??= buildTscTargets( diff --git a/packages/nx/src/hasher/file-hasher.ts b/packages/nx/src/hasher/file-hasher.ts index 58dbc73e95a2a..020e353913581 100644 --- a/packages/nx/src/hasher/file-hasher.ts +++ b/packages/nx/src/hasher/file-hasher.ts @@ -15,3 +15,9 @@ export function hashObject(obj: object): string { return hashArray(parts); } + +export function hashFile(filePath: string): string { + const { hashFile } = require('../native'); + + return hashFile(filePath); +}