fix(core): Update move/remove workspace generators to work with ts project references (#29331)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->
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 is the behavior we should expect with the changes in this PR
-->
This should work OOTB.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
This commit is contained in:
Nicholas Cunningham 2024-12-17 11:37:28 -07:00 committed by GitHub
parent 0888977c13
commit 0329cad1d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 313 additions and 30 deletions

View File

@ -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",
},
]
`);
});
});
});

View File

@ -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);
}
}
}
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}`;
}
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;
}
}

View File

@ -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([]);
});
});

View File

@ -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;
}
}
}
}

View File

@ -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<PackageJson>(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)
);
}