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