Skip to content

Monorepo: workspace subpath imports are not resolved through the target package's exports map, dropping cross-package edges #3487

Description

@cbartens

Summary

Imports of a workspace package subpath — @scope/pkg/components/Icon — are not resolved, because the importing package's dependency is never mapped to the target package's exports map. On a 21-package pnpm monorepo this drops 323 import edges from 1,233 workspace imports.

Related to #3486 but independent: that one is a suffix-rewriting bug in relative resolution, this one is a resolution path that does not exist. Both are silent, and both bias affected and any reachability pass the same way.

Reproduction

packages/ui/package.json
  { "name": "@acme/ui",
    "exports": { "./components/Icon": { "default": "./src/components/Icon.tsx" } } }
packages/ui/src/components/Icon.tsx        export function Icon() {}

apps/web/src/Page.tsx                       import { Icon } from "@acme/ui/components/Icon";

Expected: Page.tsx → packages/ui/src/components/Icon.tsx. Actual: no edge; Icon.tsx has zero inbound.

The bare-root form (import { x } from "@acme/ui") has the same problem when the package uses exports rather than main.

Three details that matter for a fix

1. types must not be used as the target. The near-universal shape is:

"./components/Icon": {
  "types":        "./dist/components/Icon.web.d.ts",
  "react-native": "./src/components/Icon.native.tsx",
  "default":      "./src/components/Icon.web.tsx"
}

types points into dist/, which is build output and normally excluded from the corpus, so resolving to it creates an edge to a node that does not exist.

2. The condition depends on the importer, not the package. An importer under a React Native app resolves react-native; a web importer resolves default. Picking one globally attributes Icon.web.tsx to mobile code that never imports it. Picking all of them everywhere inflates fan-in. Cheapest correct-enough rule is to choose by the importing file's location/platform suffix and fall back to the union when it is ambiguous.

3. Wildcard targets may not exist on disk. "./controls/*": { "default": "./src/controls/*.tsx" } where the real files are BottomNav.native.tsx and BottomNav.web.tsx — the bundler resolves that through sourceExts/resolveExtensions. An exports target that does not exist should fall through to the platform-suffixed siblings rather than being dropped.

Node's own subpath matching applies: exact key first, then a single * with a fixed prefix and suffix.

Measured impact

21-package pnpm monorepo, 1,430 source files, 10,958 nodes / 19,266 edges:

Count
Workspace imports in source 1,233
Edges missing from the graph 323

Combined with #3486, 539 edges — and the package-level architecture rollup is materially wrong as a result. Two examples after repairing both out-of-band:

Package edge graphify actual
apps/web → packages/ui 41 150
apps/mobile → packages/ui 47 98

Non-test .tsx files reading as zero-inbound fall from 44% to 14% once both gaps are closed.

This is the failure mode worth emphasising: a monorepo's most important edges are exactly the cross-package ones, and those are the ones going missing. The graph looks plausible — it just understates coupling to shared packages by 2-3×.

Note on paths

_read_tsconfig_aliases already handles compilerOptions.paths, which is why some monorepos see cross-package edges resolve. That covers repos aliasing source directly; it does not cover repos that depend on workspace packages by name and publish through exports, which is the pnpm-workspace default.

Happy to open a PR if the approach above sounds right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions