From 0329cad1d1861f11e9b0f9f610145ca38b48f49e Mon Sep 17 00:00:00 2001 From: Nicholas Cunningham Date: Tue, 17 Dec 2024 11:37:28 -0700 Subject: [PATCH] fix(core): Update move/remove workspace generators to work with ts project references (#29331) ## Current Behavior When a workspace is setup to use ts project references the move/remove generators from `@nx/workspace` do not work correctly or result in an incorrect state for the workspace. ## Expected Behavior This should work OOTB. ## Related Issue(s) Fixes # --- .../move/lib/update-imports.spec.ts | 85 +++++++++++++++ .../src/generators/move/lib/update-imports.ts | 103 ++++++++++++++---- .../remove/lib/update-tsconfig.spec.ts | 41 +++++++ .../generators/remove/lib/update-tsconfig.ts | 37 +++++-- .../utilities/typescript/ts-solution-setup.ts | 77 +++++++++++++ 5 files changed, 313 insertions(+), 30 deletions(-) create mode 100644 packages/workspace/src/utilities/typescript/ts-solution-setup.ts diff --git a/packages/workspace/src/generators/move/lib/update-imports.spec.ts b/packages/workspace/src/generators/move/lib/update-imports.spec.ts index e8f14886da0d9..8a60317845dcc 100644 --- a/packages/workspace/src/generators/move/lib/update-imports.spec.ts +++ b/packages/workspace/src/generators/move/lib/update-imports.spec.ts @@ -10,6 +10,7 @@ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { Schema } from '../schema'; import { normalizeSchema } from './normalize-schema'; import { updateImports } from './update-imports'; +import * as tsSolution from '../../../utilities/typescript/ts-solution-setup'; // nx-ignore-next-line const { libraryGenerator } = require('@nx/js'); @@ -530,4 +531,88 @@ export MyExtendedClass extends MyClass {};` '@proj/my-source/server': ['my-destination/src/server.ts'], }); }); + + describe('TypeScript project references', () => { + beforeEach(() => { + jest.spyOn(tsSolution, 'isUsingTsSolutionSetup').mockReturnValue(true); + const tsconfigContent = { + extends: './tsconfig.base.json', + ...readJson(tree, 'tsconfig.base.json'), + }; + tree.write('tsconfig.json', JSON.stringify(tsconfigContent, null, 2)); + + const packageJson = readJson(tree, 'package.json'); + packageJson.workspaces = ['packages/**']; + tree.write('package.json', JSON.stringify(packageJson, null, 2)); + }); + it('should work with updateImportPath=false', async () => { + await libraryGenerator(tree, { + directory: 'packages/my-source', + }); + + const projectConfig = readProjectConfiguration(tree, 'my-source'); + + const tsconfigJson = readJson(tree, 'tsconfig.json'); + tsconfigJson.references = [{ path: './packages/my-source' }]; + tree.write('tsconfig.json', JSON.stringify(tsconfigJson, null, 2)); + + updateImports( + tree, + await normalizeSchema( + tree, + { + ...schema, + updateImportPath: false, + }, + projectConfig + ), + + projectConfig + ); + + expect(readJson(tree, 'tsconfig.json').references).toMatchInlineSnapshot(` + [ + { + "path": "./packages/my-source", + }, + { + "path": "./my-destination", + }, + ] + `); + }); + + it('should work with updateImportPath=true', async () => { + await libraryGenerator(tree, { + directory: 'packages/my-source', + }); + + const projectConfig = readProjectConfiguration(tree, 'my-source'); + + const tsconfigJson = readJson(tree, 'tsconfig.json'); + tsconfigJson.references = [{ path: './packages/my-source' }]; + tree.write('tsconfig.json', JSON.stringify(tsconfigJson, null, 2)); + + updateImports( + tree, + await normalizeSchema( + tree, + { + ...schema, + }, + projectConfig + ), + + projectConfig + ); + + expect(readJson(tree, 'tsconfig.json').references).toMatchInlineSnapshot(` + [ + { + "path": "./my-destination", + }, + ] + `); + }); + }); }); diff --git a/packages/workspace/src/generators/move/lib/update-imports.ts b/packages/workspace/src/generators/move/lib/update-imports.ts index 712fd0dc2c37a..0a027347d336b 100644 --- a/packages/workspace/src/generators/move/lib/update-imports.ts +++ b/packages/workspace/src/generators/move/lib/update-imports.ts @@ -11,7 +11,7 @@ import { visitNotIgnoredFiles, writeJson, } from '@nx/devkit'; -import { relative } from 'path'; +import { isAbsolute, normalize, relative } from 'path'; import type * as ts from 'typescript'; import { getImportPath } from '../../../utilities/get-import-path'; import { @@ -21,6 +21,7 @@ import { import { ensureTypescript } from '../../../utilities/typescript'; import { NormalizedSchema } from '../schema'; import { normalizePathSlashes } from './utils'; +import { isUsingTsSolutionSetup } from '../../../utilities/typescript/ts-solution-setup'; let tsModule: typeof import('typescript'); @@ -41,9 +42,14 @@ export function updateImports( const { libsDir } = getWorkspaceLayout(tree); const projects = getProjects(tree); + const isUsingTsSolution = isUsingTsSolutionSetup(tree); + // use the source root to find the from location // this attempts to account for libs that have been created with --importPath - const tsConfigPath = getRootTsConfigPathInTree(tree); + const tsConfigPath = isUsingTsSolution + ? 'tsconfig.json' + : getRootTsConfigPathInTree(tree); + // If we are using a ts solution setup, we need to use tsconfig.json instead of tsconfig.base.json let tsConfig: any; let mainEntryPointImportPath: string; let secondaryEntryPointImportPaths: string[]; @@ -149,30 +155,85 @@ export function updateImports( }; if (tsConfig) { - const path = tsConfig.compilerOptions.paths[projectRef.from] as string[]; - if (!path) { - throw new Error( - [ - `unable to find "${projectRef.from}" in`, - `${tsConfigPath} compilerOptions.paths`, - ].join(' ') + if (!isUsingTsSolution) { + updateTsConfigPaths( + tsConfig, + projectRef, + tsConfigPath, + projectRoot, + schema ); - } - const updatedPath = path.map((x) => - joinPathFragments(projectRoot.to, relative(projectRoot.from, x)) - ); - - if (schema.updateImportPath && projectRef.to) { - tsConfig.compilerOptions.paths[projectRef.to] = updatedPath; - if (projectRef.from !== projectRef.to) { - delete tsConfig.compilerOptions.paths[projectRef.from]; - } } else { - tsConfig.compilerOptions.paths[projectRef.from] = updatedPath; + updateTsConfigReferences(tsConfig, projectRoot, tsConfigPath, schema); } + writeJson(tree, tsConfigPath, tsConfig); } + } +} + +function updateTsConfigReferences( + tsConfig: any, + projectRoot: { from: string; to: string }, + tsConfigPath: string, + schema: NormalizedSchema +) { + // Since paths can be './path' or 'path' we check if both are the same relative path to the workspace root + const projectRefIndex = tsConfig.references.findIndex( + (ref) => relative(ref.path, projectRoot.from) === '' + ); + if (projectRefIndex === -1) { + throw new Error( + `unable to find "${projectRoot.from}" in ${tsConfigPath} references` + ); + } + const updatedPath = joinPathFragments( + projectRoot.to, + relative(projectRoot.from, tsConfig.references[projectRefIndex].path) + ); + + let normalizedPath = normalize(updatedPath); + if ( + !normalizedPath.startsWith('.') && + !normalizedPath.startsWith('../') && + !isAbsolute(normalizedPath) + ) { + normalizedPath = `./${normalizedPath}`; + } - writeJson(tree, tsConfigPath, tsConfig); + if (schema.updateImportPath && projectRoot.to) { + tsConfig.references[projectRefIndex].path = normalizedPath; + } else { + tsConfig.references.push({ path: normalizedPath }); + } +} + +function updateTsConfigPaths( + tsConfig: any, + projectRef: { from: string; to: string }, + tsConfigPath: string, + projectRoot: { from: string; to: string }, + schema: NormalizedSchema +) { + const path = tsConfig.compilerOptions.paths[projectRef.from] as string[]; + if (!path) { + throw new Error( + [ + `unable to find "${projectRef.from}" in`, + `${tsConfigPath} compilerOptions.paths`, + ].join(' ') + ); + } + const updatedPath = path.map((x) => + joinPathFragments(projectRoot.to, relative(projectRoot.from, x)) + ); + + if (schema.updateImportPath && projectRef.to) { + tsConfig.compilerOptions.paths[projectRef.to] = updatedPath; + if (projectRef.from !== projectRef.to) { + delete tsConfig.compilerOptions.paths[projectRef.from]; + } + } else { + tsConfig.compilerOptions.paths[projectRef.from] = updatedPath; } } diff --git a/packages/workspace/src/generators/remove/lib/update-tsconfig.spec.ts b/packages/workspace/src/generators/remove/lib/update-tsconfig.spec.ts index 50337784c5831..d526b2767a4bb 100644 --- a/packages/workspace/src/generators/remove/lib/update-tsconfig.spec.ts +++ b/packages/workspace/src/generators/remove/lib/update-tsconfig.spec.ts @@ -7,6 +7,7 @@ import { import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { Schema } from '../schema'; import { updateTsconfig } from './update-tsconfig'; +import * as tsSolution from '../../../utilities/typescript/ts-solution-setup'; // nx-ignore-next-line const { libraryGenerator } = require('@nx/js'); @@ -212,4 +213,44 @@ describe('updateTsconfig', () => { '@proj/nested/whatever-name': ['libs/my-lib/nested-lib/src/index.ts'], }); }); + + it('should work with tsSolution setup', async () => { + jest.spyOn(tsSolution, 'isUsingTsSolutionSetup').mockReturnValue(true); + + await libraryGenerator(tree, { + directory: 'my-lib', + }); + + const tsconfigContent = { + extends: './tsconfig.base.json', + compilerOptions: {}, + files: [], + include: [], + references: [ + { + path: './my-lib', + }, + ], + }; + + tree.write('tsconfig.json', JSON.stringify(tsconfigContent, null, 2)); + + graph = { + nodes: { + 'my-lib': { + name: 'my-lib', + type: 'lib', + data: { + root: readProjectConfiguration(tree, 'my-lib').root, + } as any, + }, + }, + dependencies: {}, + }; + + await updateTsconfig(tree, schema); + + const tsConfig = readJson(tree, 'tsconfig.json'); + expect(tsConfig.references).toEqual([]); + }); }); diff --git a/packages/workspace/src/generators/remove/lib/update-tsconfig.ts b/packages/workspace/src/generators/remove/lib/update-tsconfig.ts index a1caf16d2f2df..a4be54e275316 100644 --- a/packages/workspace/src/generators/remove/lib/update-tsconfig.ts +++ b/packages/workspace/src/generators/remove/lib/update-tsconfig.ts @@ -2,6 +2,7 @@ import { createProjectGraphAsync, normalizePath, ProjectGraph, + readProjectsConfigurationFromProjectGraph, Tree, updateJson, } from '@nx/devkit'; @@ -11,6 +12,8 @@ import { createProjectRootMappings, findProjectForPath, } from 'nx/src/project-graph/utils/find-project-for-path'; +import { isUsingTsSolutionSetup } from '../../../utilities/typescript/ts-solution-setup'; +import { relative } from 'path'; /** * Updates the tsconfig paths to remove the project. @@ -18,21 +21,37 @@ import { * @param schema The options provided to the schematic */ export async function updateTsconfig(tree: Tree, schema: Schema) { - const tsConfigPath = getRootTsConfigPathInTree(tree); + const isUsingTsSolution = isUsingTsSolutionSetup(tree); + const tsConfigPath = isUsingTsSolution + ? 'tsconfig.json' + : getRootTsConfigPathInTree(tree); if (tree.exists(tsConfigPath)) { const graph: ProjectGraph = await createProjectGraphAsync(); const projectMapping = createProjectRootMappings(graph.nodes); updateJson(tree, tsConfigPath, (json) => { - for (const importPath in json.compilerOptions.paths) { - for (const path of json.compilerOptions.paths[importPath]) { - const project = findProjectForPath( - normalizePath(path), - projectMapping + if (isUsingTsSolution) { + const projectConfigs = readProjectsConfigurationFromProjectGraph(graph); + const project = projectConfigs.projects[schema.projectName]; + if (!project) { + throw new Error( + `Could not find project '${schema.project}'. Please choose a project that exists in the Nx Workspace.` ); - if (project === schema.projectName) { - delete json.compilerOptions.paths[importPath]; - break; + } + json.references = json.references.filter( + (ref) => relative(ref.path, project.root) !== '' + ); + } else { + for (const importPath in json.compilerOptions.paths) { + for (const path of json.compilerOptions.paths[importPath]) { + const project = findProjectForPath( + normalizePath(path), + projectMapping + ); + if (project === schema.projectName) { + delete json.compilerOptions.paths[importPath]; + break; + } } } } diff --git a/packages/workspace/src/utilities/typescript/ts-solution-setup.ts b/packages/workspace/src/utilities/typescript/ts-solution-setup.ts new file mode 100644 index 0000000000000..eddd5b396c5c6 --- /dev/null +++ b/packages/workspace/src/utilities/typescript/ts-solution-setup.ts @@ -0,0 +1,77 @@ +import { + detectPackageManager, + readJson, + type Tree, + workspaceRoot, +} from '@nx/devkit'; +import { FsTree } from 'nx/src/generators/tree'; +import { type PackageJson } from 'nx/src/utils/package-json'; + +function isUsingPackageManagerWorkspaces(tree: Tree): boolean { + return isWorkspacesEnabled(tree); +} + +function isWorkspacesEnabled(tree: Tree): boolean { + const packageManager = detectPackageManager(tree.root); + if (packageManager === 'pnpm') { + return tree.exists('pnpm-workspace.yaml'); + } + + // yarn and npm both use the same 'workspaces' property in package.json + if (tree.exists('package.json')) { + const packageJson = readJson(tree, 'package.json'); + return !!packageJson?.workspaces; + } + return false; +} + +function isWorkspaceSetupWithTsSolution(tree: Tree): boolean { + if (!tree.exists('tsconfig.base.json') || !tree.exists('tsconfig.json')) { + return false; + } + + const tsconfigJson = readJson(tree, 'tsconfig.json'); + if (tsconfigJson.extends !== './tsconfig.base.json') { + return false; + } + + /** + * New setup: + * - `files` is defined and set to an empty array + * - `references` is defined and set to an empty array + * - `include` is not defined or is set to an empty array + */ + if ( + !tsconfigJson.files || + tsconfigJson.files.length > 0 || + !tsconfigJson.references || + !!tsconfigJson.include?.length + ) { + return false; + } + + const baseTsconfigJson = readJson(tree, 'tsconfig.base.json'); + if ( + !baseTsconfigJson.compilerOptions || + !baseTsconfigJson.compilerOptions.composite || + !baseTsconfigJson.compilerOptions.declaration + ) { + return false; + } + + const { compilerOptions, ...rest } = baseTsconfigJson; + if (Object.keys(rest).length > 0) { + return false; + } + + return true; +} + +export function isUsingTsSolutionSetup(tree?: Tree): boolean { + tree ??= new FsTree(workspaceRoot, false); + + return ( + isUsingPackageManagerWorkspaces(tree) && + isWorkspaceSetupWithTsSolution(tree) + ); +}