From 4341d2124a62241c2af518287d689c9e90499dbc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 24 Feb 2026 10:22:07 +0000 Subject: [PATCH] fix(js): use per-invocation cache in TS plugin to fix NX_ISOLATE_PLUGINS=false When plugin isolation is off, concurrent createNodesV2 invocations share the same module instance. The module-level mutable `cache` variable caused invocation A's `finally` block to null it out while invocation B was still reading from it, resulting in "Cannot read properties of null (reading 'configContexts')". Replace the shared mutable `cache` with a Symbol-keyed Map so each invocation gets its own isolated cache. The tsconfig disk cache is shared across invocations with an idempotent initialization guard. --- .../js/src/plugins/typescript/plugin.spec.ts | 27 ++ packages/js/src/plugins/typescript/plugin.ts | 233 +++++++++++++----- 2 files changed, 193 insertions(+), 67 deletions(-) diff --git a/packages/js/src/plugins/typescript/plugin.spec.ts b/packages/js/src/plugins/typescript/plugin.spec.ts index f26992c23a5..5fd4bffef58 100644 --- a/packages/js/src/plugins/typescript/plugin.spec.ts +++ b/packages/js/src/plugins/typescript/plugin.spec.ts @@ -6092,6 +6092,33 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { `); }); }); + + it('should handle concurrent invocations without crashing', async () => { + configFiles = await applyFilesToTempFsAndContext(tempFs, context, { + 'libs/my-lib/tsconfig.json': JSON.stringify({ + compilerOptions: { outDir: '../../dist/libs/my-lib' }, + files: [], + }), + 'libs/my-lib/package.json': `{}`, + 'libs/other-lib/tsconfig.json': JSON.stringify({ + compilerOptions: { outDir: '../../dist/libs/other-lib' }, + files: [], + }), + 'libs/other-lib/package.json': `{}`, + }); + + const [result1, result2] = await Promise.all([ + invokeCreateNodesOnMatchingFiles(configFiles, context, {}), + invokeCreateNodesOnMatchingFiles(configFiles, context, {}), + ]); + + expect(result1.projects['libs/my-lib']).toBeDefined(); + expect(result1.projects['libs/other-lib']).toBeDefined(); + expect(result2.projects['libs/my-lib']).toBeDefined(); + expect(result2.projects['libs/other-lib']).toBeDefined(); + expect(result1.projects['libs/my-lib'].targets.typecheck).toBeDefined(); + expect(result2.projects['libs/my-lib'].targets.typecheck).toBeDefined(); + }); }); async function applyFilesToTempFsAndContext( diff --git a/packages/js/src/plugins/typescript/plugin.ts b/packages/js/src/plugins/typescript/plugin.ts index ca1e77f8d0f..810bb2fc26c 100644 --- a/packages/js/src/plugins/typescript/plugin.ts +++ b/packages/js/src/plugins/typescript/plugin.ts @@ -118,8 +118,7 @@ const TS_CONFIG_CACHE_PATH = join( workspaceDataDirectory, 'tsconfig-files.hash' ); -let tsConfigCacheData: Record; -let cache: { +type InvocationCache = { fileHashes: Record; rawFiles: Record; picomatchMatchers: Record boolean>; @@ -129,6 +128,13 @@ let cache: { configContexts: Map; }; +// Module-level cache store — each invocation gets a unique Symbol key +const cacheStore = new Map(); + +// Shared tsconfig cache — initialized once per batch, persisted to disk +let tsConfigCacheData: Record = {}; +let tsConfigCacheInitialized = false; + function readFromCache(cachePath: string): T { try { return process.env.NX_CACHE_PROJECT_GRAPH !== 'false' @@ -191,7 +197,8 @@ function createConfigContext( function getConfigContext( configPath: string, - workspaceRoot: string + workspaceRoot: string, + cache: InvocationCache ): ConfigContext { const absolutePath = configPath.startsWith('/') || configPath.startsWith(workspaceRoot) @@ -245,7 +252,10 @@ export const createNodesV2: CreateNodesV2 = [ ); const targetsCache = readFromCache>(targetsCachePath); - cache = { + + // Each invocation gets a unique Symbol key — guaranteed no collisions + const cacheKey = Symbol('tsc-invocation'); + cacheStore.set(cacheKey, { fileHashes: {}, rawFiles: {}, picomatchMatchers: {}, @@ -253,8 +263,10 @@ export const createNodesV2: CreateNodesV2 = [ configOwners: new Map(), projectContexts: new Map(), configContexts: new Map(), - }; - initializeTsConfigCache(configFilePaths, context.workspaceRoot); + }); + const cache = cacheStore.get(cacheKey)!; + + initializeTsConfigCache(configFilePaths, context.workspaceRoot, cache); const normalizedOptions = normalizePluginOptions(options); @@ -266,7 +278,8 @@ export const createNodesV2: CreateNodesV2 = [ configFilePaths, normalizedOptions, optionsHash, - context + context, + cache ); try { @@ -274,22 +287,24 @@ export const createNodesV2: CreateNodesV2 = [ (configFilePath, options, context, idx) => { const projectRoot = projectRoots[idx]; const hash = hashes[idx]; - const cacheKey = `${hash}_${configFilePath}`; + const targetsCacheKey = `${hash}_${configFilePath}`; const absolutePath = join(context.workspaceRoot, configFilePath); const configContext = getConfigContext( absolutePath, - context.workspaceRoot + context.workspaceRoot, + cache ); - targetsCache[cacheKey] ??= buildTscTargets( + targetsCache[targetsCacheKey] ??= buildTscTargets( configContext, options, context, - validConfigFilePaths + validConfigFilePaths, + cache ); - const { targets } = targetsCache[cacheKey]; + const { targets } = targetsCache[targetsCacheKey]; return { projects: { @@ -308,8 +323,13 @@ export const createNodesV2: CreateNodesV2 = [ writeTsConfigCache( toRelativePaths(tsConfigCacheData, context.workspaceRoot) ); - // Release memory after plugin invocation - cache = null as any; + // Delete this invocation's cache — unique Symbol means no cross-invocation impact + cacheStore.delete(cacheKey); + // Reset shared tsconfig cache when all invocations are done + if (cacheStore.size === 0) { + tsConfigCacheData = {}; + tsConfigCacheInitialized = false; + } } }, ]; @@ -320,7 +340,8 @@ async function resolveValidConfigFilesAndHashes( configFilePaths: readonly string[], options: NormalizedPluginOptions, optionsHash: string, - context: CreateNodesContextV2 + context: CreateNodesContextV2, + cache: InvocationCache ): Promise<{ configFilePaths: string[]; hashes: string[]; @@ -341,7 +362,11 @@ async function resolveValidConfigFilesAndHashes( for await (const configFilePath of configFilePaths) { const projectRoot = dirname(configFilePath); const absolutePath = join(context.workspaceRoot, configFilePath); - const configContext = getConfigContext(absolutePath, context.workspaceRoot); + const configContext = getConfigContext( + absolutePath, + context.workspaceRoot, + cache + ); // Skip configs that can't produce any targets based on plugin options const isTypecheckConfig = configContext.basename === 'tsconfig.json'; @@ -351,7 +376,7 @@ async function resolveValidConfigFilesAndHashes( continue; } - if (!checkIfConfigFileShouldBeProject(configContext)) { + if (!checkIfConfigFileShouldBeProject(configContext, cache)) { continue; } @@ -363,7 +388,8 @@ async function resolveValidConfigFilesAndHashes( context.workspaceRoot, configContext.project, optionsHash, - lockFileHash + lockFileHash, + cache ) ); } @@ -388,21 +414,32 @@ async function getConfigFileHash( workspaceRoot: string, project: ProjectContext, optionsHash: string, - lockFileHash: string + lockFileHash: string, + cache: InvocationCache ): Promise { const fullConfigPath = join(workspaceRoot, configFilePath); - const tsConfig = retrieveTsConfigFromCache(fullConfigPath, workspaceRoot); - const extendedConfigFiles = getExtendedConfigFiles(tsConfig, workspaceRoot); + const tsConfig = retrieveTsConfigFromCache( + fullConfigPath, + workspaceRoot, + cache + ); + const extendedConfigFiles = getExtendedConfigFiles( + tsConfig, + workspaceRoot, + cache + ); const internalReferencedFiles = resolveInternalProjectReferences( tsConfig, workspaceRoot, - project + project, + cache ); const externalProjectReferences = resolveShallowExternalProjectReferences( tsConfig, workspaceRoot, - project + project, + cache ); let packageJson = null; @@ -418,7 +455,7 @@ async function getConfigFileHash( ...extendedConfigFiles.files.sort(), ...Object.keys(internalReferencedFiles).sort(), ...Object.keys(externalProjectReferences).sort(), - ].map((file) => getFileHash(file, workspaceRoot)), + ].map((file) => getFileHash(file, workspaceRoot, cache)), ...extendedConfigFiles.packages.sort(), lockFileHash, optionsHash, @@ -426,7 +463,10 @@ async function getConfigFileHash( ]); } -function checkIfConfigFileShouldBeProject(config: ConfigContext): boolean { +function checkIfConfigFileShouldBeProject( + config: ConfigContext, + cache: InvocationCache +): boolean { // Do not create a project for the workspace root tsconfig files. if (config.project.root === '.') { return false; @@ -469,13 +509,15 @@ function buildTscTargets( config: ConfigContext, options: NormalizedPluginOptions, context: CreateNodesContextV2, - configFiles: readonly string[] + configFiles: readonly string[], + cache: InvocationCache ) { const targets: Record = {}; const namedInputs = getNamedInputs(config.project.root, context); const tsConfig = retrieveTsConfigFromCache( config.absolutePath, - context.workspaceRoot + context.workspaceRoot, + cache ); let internalProjectReferences: Record; @@ -487,12 +529,14 @@ function buildTscTargets( internalProjectReferences = resolveInternalProjectReferences( tsConfig, context.workspaceRoot, - config.project + config.project, + cache ); const externalProjectReferences = resolveShallowExternalProjectReferences( tsConfig, context.workspaceRoot, - config.project + config.project, + cache ); const targetName = options.typecheck.targetName; const compiler = options.compiler; @@ -527,7 +571,11 @@ function buildTscTargets( configFiles.some((f) => f === buildConfigPath) && (options.build.skipBuildCheck || isValidPackageJsonBuildConfig( - retrieveTsConfigFromCache(buildConfigPath, context.workspaceRoot), + retrieveTsConfigFromCache( + buildConfigPath, + context.workspaceRoot, + cache + ), context.workspaceRoot, config.project.root )) @@ -546,7 +594,8 @@ function buildTscTargets( config, tsConfig, internalProjectReferences, - context.workspaceRoot + context.workspaceRoot, + cache ), outputs: getOutputs( config, @@ -584,7 +633,8 @@ function buildTscTargets( internalProjectReferences ??= resolveInternalProjectReferences( tsConfig, context.workspaceRoot, - config.project + config.project, + cache ); const targetName = options.build.targetName; const compiler = options.compiler; @@ -601,7 +651,8 @@ function buildTscTargets( config, tsConfig, internalProjectReferences, - context.workspaceRoot + context.workspaceRoot, + cache ), outputs: getOutputs( config, @@ -644,14 +695,19 @@ function getInputs( config: ConfigContext, tsConfig: ParsedTsconfigData, internalProjectReferences: Record, - workspaceRoot: string + workspaceRoot: string, + cache: InvocationCache ): TargetConfiguration['inputs'] { const configFiles = new Set(); // TODO(leo): temporary disable external dependencies until we support hashing // glob patterns from external dependencies // const externalDependencies = ['typescript']; - const extendedConfigFiles = getExtendedConfigFiles(tsConfig, workspaceRoot); + const extendedConfigFiles = getExtendedConfigFiles( + tsConfig, + workspaceRoot, + cache + ); extendedConfigFiles.files.forEach((configPath) => { configFiles.add(configPath); }); @@ -816,7 +872,8 @@ function getInputs( config.originalPath, tsConfig, workspaceRoot, - config.project + config.project, + cache ) ) { // Importing modules from a referenced project will load its output declaration files (d.ts) @@ -1028,6 +1085,7 @@ function pathToInputOrOutput( function getExtendedConfigFiles( tsConfig: ParsedTsconfigData, workspaceRoot: string, + cache: InvocationCache, extendedConfigFiles = new Set(), extendedExternalPackages = new Set() ): { @@ -1040,8 +1098,13 @@ function getExtendedConfigFiles( } else if (extendedConfigFile.filePath) { extendedConfigFiles.add(extendedConfigFile.filePath); getExtendedConfigFiles( - retrieveTsConfigFromCache(extendedConfigFile.filePath, workspaceRoot), + retrieveTsConfigFromCache( + extendedConfigFile.filePath, + workspaceRoot, + cache + ), workspaceRoot, + cache, extendedConfigFiles, extendedExternalPackages ); @@ -1058,6 +1121,7 @@ function resolveInternalProjectReferences( tsConfig: ParsedTsconfigData, workspaceRoot: string, project: ProjectContext, + cache: InvocationCache, projectReferences: Record = {} ): Record { if (!tsConfig.projectReferences?.length) { @@ -1078,20 +1142,22 @@ function resolveInternalProjectReferences( refConfigPath = join(refConfigPath, 'tsconfig.json'); } - const refContext = getConfigContext(refConfigPath, workspaceRoot); + const refContext = getConfigContext(refConfigPath, workspaceRoot, cache); - if (isExternalProjectReference(refContext, project, workspaceRoot)) { + if (isExternalProjectReference(refContext, project, workspaceRoot, cache)) { continue; } projectReferences[refConfigPath] = retrieveTsConfigFromCache( refConfigPath, - workspaceRoot + workspaceRoot, + cache ); resolveInternalProjectReferences( projectReferences[refConfigPath], workspaceRoot, project, + cache, projectReferences ); } @@ -1103,6 +1169,7 @@ function resolveShallowExternalProjectReferences( tsConfig: ParsedTsconfigData, workspaceRoot: string, project: ProjectContext, + cache: InvocationCache, projectReferences: Record = {} ): Record { if (!tsConfig.projectReferences?.length) { @@ -1123,12 +1190,13 @@ function resolveShallowExternalProjectReferences( refConfigPath = join(refConfigPath, 'tsconfig.json'); } - const refContext = getConfigContext(refConfigPath, workspaceRoot); + const refContext = getConfigContext(refConfigPath, workspaceRoot, cache); - if (isExternalProjectReference(refContext, project, workspaceRoot)) { + if (isExternalProjectReference(refContext, project, workspaceRoot, cache)) { projectReferences[refConfigPath] = retrieveTsConfigFromCache( refConfigPath, - workspaceRoot + workspaceRoot, + cache ); } } @@ -1141,6 +1209,7 @@ function hasExternalProjectReferences( tsConfig: ParsedTsconfigData, workspaceRoot: string, project: ProjectContext, + cache: InvocationCache, seen = new Set() ): boolean { if (!tsConfig.projectReferences?.length) { @@ -1162,17 +1231,22 @@ function hasExternalProjectReferences( refConfigPath = join(refConfigPath, 'tsconfig.json'); } - const refContext = getConfigContext(refConfigPath, workspaceRoot); + const refContext = getConfigContext(refConfigPath, workspaceRoot, cache); - if (isExternalProjectReference(refContext, project, workspaceRoot)) { + if (isExternalProjectReference(refContext, project, workspaceRoot, cache)) { return true; } - const refTsConfig = retrieveTsConfigFromCache(refConfigPath, workspaceRoot); + const refTsConfig = retrieveTsConfigFromCache( + refConfigPath, + workspaceRoot, + cache + ); const result = hasExternalProjectReferences( refConfigPath, refTsConfig, workspaceRoot, project, + cache, seen ); @@ -1187,7 +1261,8 @@ function hasExternalProjectReferences( function isExternalProjectReference( refConfig: ConfigContext, project: ProjectContext, - workspaceRoot: string + workspaceRoot: string, + cache: InvocationCache ): boolean { const owner = cache.configOwners.get(refConfig.relativePath); if (owner !== undefined) { @@ -1217,7 +1292,8 @@ function isExternalProjectReference( function retrieveTsConfigFromCache( tsConfigPath: string, - workspaceRoot: string + workspaceRoot: string, + cache: InvocationCache ): ParsedTsconfigData { const relativePath = posixRelative(workspaceRoot, tsConfigPath); @@ -1225,28 +1301,33 @@ function retrieveTsConfigFromCache( // checked it when we initially populated the cache return tsConfigCacheData[relativePath] ? tsConfigCacheData[relativePath].data - : readTsConfigAndCache(tsConfigPath, workspaceRoot); + : readTsConfigAndCache(tsConfigPath, workspaceRoot, cache); } function initializeTsConfigCache( configFilePaths: readonly string[], - workspaceRoot: string + workspaceRoot: string, + cache: InvocationCache ): void { - tsConfigCacheData = toAbsolutePaths(readTsConfigCacheData(), workspaceRoot); + if (!tsConfigCacheInitialized) { + tsConfigCacheData = toAbsolutePaths(readTsConfigCacheData(), workspaceRoot); + tsConfigCacheInitialized = true; + } // ensure hashes are checked and the cache is invalidated and populated as needed for (const configFilePath of configFilePaths) { const fullConfigPath = join(workspaceRoot, configFilePath); - readTsConfigAndCache(fullConfigPath, workspaceRoot); + readTsConfigAndCache(fullConfigPath, workspaceRoot, cache); } } function readTsConfigAndCache( tsConfigPath: string, - workspaceRoot: string + workspaceRoot: string, + cache: InvocationCache ): ParsedTsconfigData { const relativePath = posixRelative(workspaceRoot, tsConfigPath); - const hash = getFileHash(tsConfigPath, workspaceRoot); + const hash = getFileHash(tsConfigPath, workspaceRoot, cache); let extendedFilesHash: string; if ( @@ -1255,7 +1336,8 @@ function readTsConfigAndCache( ) { extendedFilesHash = getExtendedFilesHash( tsConfigCacheData[relativePath].data.extendedConfigFiles, - workspaceRoot + workspaceRoot, + cache ); if ( tsConfigCacheData[relativePath].extendedFilesHash === extendedFilesHash @@ -1264,7 +1346,7 @@ function readTsConfigAndCache( } } - const tsConfig = readTsConfig(tsConfigPath, workspaceRoot); + const tsConfig = readTsConfig(tsConfigPath, workspaceRoot, cache); const extendedConfigFiles: ExtendedConfigFile[] = []; if (tsConfig.raw?.extends) { const extendsArray = @@ -1283,7 +1365,8 @@ function readTsConfigAndCache( } extendedFilesHash ??= getExtendedFilesHash( extendedConfigFiles, - workspaceRoot + workspaceRoot, + cache ); tsConfigCacheData[relativePath] = { @@ -1302,7 +1385,8 @@ function readTsConfigAndCache( function getExtendedFilesHash( extendedConfigFiles: ExtendedConfigFile[], - workspaceRoot: string + workspaceRoot: string, + cache: InvocationCache ): string { if (!extendedConfigFiles.length) { return ''; @@ -1324,12 +1408,18 @@ function getExtendedFilesHash( if (extendedConfigFile.externalPackage) { hashes.push(extendedConfigFile.externalPackage); } else if (extendedConfigFile.filePath) { - hashes.push(getFileHash(extendedConfigFile.filePath, workspaceRoot)); + hashes.push( + getFileHash(extendedConfigFile.filePath, workspaceRoot, cache) + ); hashes.push( getExtendedFilesHash( - readTsConfigAndCache(extendedConfigFile.filePath, workspaceRoot) - .extendedConfigFiles, - workspaceRoot + readTsConfigAndCache( + extendedConfigFile.filePath, + workspaceRoot, + cache + ).extendedConfigFiles, + workspaceRoot, + cache ) ); } @@ -1343,7 +1433,8 @@ function getExtendedFilesHash( function readTsConfig( tsConfigPath: string, - workspaceRoot: string + workspaceRoot: string, + cache: InvocationCache ): ParsedCommandLine { if (!ts) { ts = require('typescript'); @@ -1351,7 +1442,7 @@ function readTsConfig( const tsSys: System = { ...ts.sys, - readFile: (path) => readFile(path, workspaceRoot), + readFile: (path) => readFile(path, workspaceRoot, cache), readDirectory: () => [], }; const readResult = ts.readConfigFile(tsConfigPath, tsSys.readFile); @@ -1444,17 +1535,25 @@ function resolveExtendedTsConfigPath( } } -function getFileHash(filePath: string, workspaceRoot: string): string { +function getFileHash( + filePath: string, + workspaceRoot: string, + cache: InvocationCache +): string { const relativePath = posixRelative(workspaceRoot, filePath); if (!cache.fileHashes[relativePath]) { - const content = readFile(filePath, workspaceRoot); + const content = readFile(filePath, workspaceRoot, cache); cache.fileHashes[relativePath] = hashArray([content]); } return cache.fileHashes[relativePath]; } -function readFile(filePath: string, workspaceRoot: string): string { +function readFile( + filePath: string, + workspaceRoot: string, + cache: InvocationCache +): string { const relativePath = posixRelative(workspaceRoot, filePath); if (!cache.rawFiles[relativePath]) { const content = readFileSync(filePath, 'utf8');