From d08ad7504f2d2e3a840d623c90db9723d63a346e Mon Sep 17 00:00:00 2001 From: Robb Traister <robb.traister@gmail.com> Date: Fri, 10 Jan 2025 13:32:33 -0500 Subject: [PATCH] fix(core): support subpath exports when constructing the project graph (#29577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling dependencies that rely exclusively on subpath exports are excluded from the dependency graph because there is no exact match. This adds a fallback to look for subpath exports if the exact match is not found. This also adds logic to respect conditional exports independent from subpath exports. <!-- 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 Importing a workspace dependency via subpath export fails to match the package name, and so is not included in the dependency graph. ### Example ```apps/api/package.json``` ```json "name": "@my-org/api", "dependencies": { "@my-org/services": "workspace:*" } ``` ```libs/services/package.json``` ```json "name": "@my-org/services", "exports": { "./email": "./dist/email.js" } ``` The `@my-org/api` app should be able to import the email service with `import { EmailService } from "@my-org/services/email"`. However, the `getPackageEntryPointsToProjectMap` implementation results in an object with a key of `@my-org/services/email`, but not `@my-org/services`. This is not specifically a problem, except that `findDependencyInWorkspaceProjects` only considers exact matches within those object keys. ## Expected Behavior Importing a workspace dependency via subpath export should be included in the dependency graph. I also addressed a related issue where the following resulted in keys of `@my-org/services/default` and `@my-org/services/types`, which is incorrect according to the subpath/conditional export rules. ```json "exports": { "default": "./dist/index.js", "types": "./dist/index.d.ts" } ``` ## Related Issue(s) <!-- Please link the issue being fixed so it gets closed when this is merged. --> Fixes #29486 --------- Co-authored-by: Leosvel PĂ©rez Espinosa <leosvel.perez.espinosa@gmail.com> --- .../target-project-locator.spec.ts | 46 +++++++++++++++++++ .../target-project-locator.ts | 9 +++- packages/nx/src/plugins/js/utils/packages.ts | 19 +++++++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts index a740e3c3263c7c..b94ebe7f2ccd61 100644 --- a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts +++ b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts @@ -1011,6 +1011,52 @@ describe('TargetProjectLocator', () => { expect(result).toEqual('npm:foo@0.0.1'); }); }); + + describe('findDependencyInWorkspaceProjects', () => { + it.each` + pkgName | project | exports | dependency + ${'@org/pkg1'} | ${'pkg1'} | ${undefined} | ${'@org/pkg1'} + ${'@org/pkg1'} | ${'pkg1'} | ${undefined} | ${'@org/pkg1/subpath'} + ${'@org/pkg1'} | ${'pkg1'} | ${'dist/index.js'} | ${'@org/pkg1'} + ${'@org/pkg1'} | ${'pkg1'} | ${{}} | ${'@org/pkg1'} + ${'@org/pkg1'} | ${'pkg1'} | ${{}} | ${'@org/pkg1/subpath'} + ${'@org/pkg1'} | ${'pkg1'} | ${{ '.': 'dist/index.js' }} | ${'@org/pkg1'} + ${'@org/pkg1'} | ${'pkg1'} | ${{ '.': 'dist/index.js' }} | ${'@org/pkg1/subpath'} + ${'@org/pkg1'} | ${'pkg1'} | ${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1'} + ${'@org/pkg1'} | ${'pkg1'} | ${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath'} + ${'@org/pkg1'} | ${'pkg1'} | ${{ import: './dist/index.js', default: './dist/index.js' }} | ${'@org/pkg1'} + ${'@org/pkg1'} | ${'pkg1'} | ${{ import: './dist/index.js', default: './dist/index.js' }} | ${'@org/pkg1/subpath'} + `( + 'should find "$dependency" as "$project" when exports="$exports"', + ({ pkgName, project, exports, dependency }) => { + let projects: Record<string, ProjectGraphProjectNode> = { + [project]: { + name: project, + type: 'lib' as const, + data: { + root: project, + metadata: { + js: { + packageName: pkgName, + packageExports: exports, + }, + }, + }, + }, + }; + + const targetProjectLocator = new TargetProjectLocator( + projects, + {}, + new Map() + ); + const result = + targetProjectLocator.findDependencyInWorkspaceProjects(dependency); + + expect(result).toEqual(project); + } + ); + }); }); describe('isBuiltinModuleImport()', () => { diff --git a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts index e4752cd89852c2..daca3da58f76fb 100644 --- a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts +++ b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts @@ -259,7 +259,14 @@ export class TargetProjectLocator { this.nodes ); - return this.packageEntryPointsToProjectMap[dep]?.name ?? null; + return ( + this.packageEntryPointsToProjectMap[dep]?.name ?? + // if the package exports do not include ".", look for subpath exports + Object.entries(this.packageEntryPointsToProjectMap).find(([entryPoint]) => + dep.startsWith(`${entryPoint}/`) + )?.[1]?.name ?? + null + ); } private resolveImportWithTypescript( diff --git a/packages/nx/src/plugins/js/utils/packages.ts b/packages/nx/src/plugins/js/utils/packages.ts index 0625deedec6888..f3a257db4c7fb3 100644 --- a/packages/nx/src/plugins/js/utils/packages.ts +++ b/packages/nx/src/plugins/js/utils/packages.ts @@ -15,13 +15,28 @@ export function getPackageEntryPointsToProjectMap< } const { packageName, packageExports } = metadata.js; - if (!packageExports || typeof packageExports === 'string') { + if ( + !packageExports || + typeof packageExports === 'string' || + !Object.keys(packageExports).length + ) { // no `exports` or it points to a file, which would be the equivalent of // an '.' export, in which case the package name is the entry point result[packageName] = project; } else { for (const entryPoint of Object.keys(packageExports)) { - result[join(packageName, entryPoint)] = project; + // if entrypoint begins with '.', it is a relative subpath export + // otherwise, it is a conditional export + // https://nodejs.org/api/packages.html#conditional-exports + if (entryPoint.startsWith('.')) { + result[join(packageName, entryPoint)] = project; + } else { + result[packageName] = project; + } + } + // if there was no '.' entrypoint, ensure the package name is matched with the project + if (!result[packageName]) { + result[packageName] = project; } } }