Skip to content

Global virtual store: hoisted-resolution bridge misses the workspace root's node_modules #10588

Description

@zkochan

Summary

Under pnpm's global virtual store (enableGlobalVirtualStore: true), the hoisted-resolution bridge restores only half of the resolution surface a package had under the project-local virtual store. A package living in a store slot can still not reach the workspace's own node_modules, so any phantom require of a direct dependency of the workspace root fails.

Background

ensureHoistedDependencyResolution (hoisted-resolution-bridge.ts:205) puts one directory on NODE_PATH (plus the ESM loader):

const hoistedDir = path.join(workspaceRoot, 'node_modules', '.pnpm', 'node_modules');

Under the project-local virtual store, a package at node_modules/.pnpm/<pkg>/node_modules/<pkg> resolved undeclared requires through node's ancestor walk, which reaches two directories:

  1. node_modules/.pnpm/node_modules — the hoisted dir, from the node_modules/.pnpm ancestor
  2. <workspaceRoot>/node_modules — from the workspace root ancestor

Under the global virtual store the package's realpath is <storeDir>/links/@/<pkg>/<version>/<hash>/node_modules/<pkg>, with no workspace above it, so both are lost. The bridge replaces (1) and nothing replaces (2).

This matters because pnpm hoists only non-direct dependencies into node_modules/.pnpm/node_modules. Direct dependencies of the workspace root exist only in <workspaceRoot>/node_modules, so they are exactly the set the bridge cannot cover. Observed in the bit repo itself:

mocha          hoisted:no  root:yes
oxlint         hoisted:no  root:yes
cross-env      hoisted:no  root:yes
lodash.get     hoisted:yes root:no

Reproduction

In a workspace with enableGlobalVirtualStore: true, have a store-resident package require a direct dependency of the workspace root that it does not declare itself. Concretely, in the bit repo (which enables it on the enable-gvs branch), mocha requiring its reporter:

$ ./node_modules/.bin/mocha --reporter mocha-multi-reporters ...
✖ ERROR: TypeError: Could not load reporter "mocha-multi-reporters":
 Error: Cannot find module '<workspace>/mocha-multi-reporters'
Require stack:
- <storeDir>/links/@/mocha/11.1.0/<hash>/node_modules/mocha/lib/cli/run-helpers.js

mocha-multi-reporters is a direct dependency of the workspace root. This particular invocation is not started by bit, so it gets no bridge at all — but the same require fails identically inside a bit-run process, because the bridge does not carry the workspace root's node_modules.

Proposed fix

Add <workspaceRoot>/node_modules to the entries the bridge contributes, after the hoisted dir, matching the order of the original ancestor walk. Both halves already generalize over a list — the CommonJS side through NODE_PATH, and the ESM loader iterates extraNodePaths — so this is a matter of what ensureHoistedDependencyResolution puts on the path.

Points worth deciding in review:

  • ensureSelfInstallationBridge uses the same function against the installation root, where <root>/node_modules is the installation's own package set. That is the behaviour the project-local layout had too, so it looks correct, but it widens what a bvm-installed bit can resolve and deserves a deliberate call.
  • The ESM loader anchors createRequire inside each entry and relies on node not appending /node_modules to a directory already named node_modules — the comment at hoisted-resolution-bridge.ts:70 notes this holds exactly for entries named node_modules. The new entry is also named node_modules, so it stays in that case.

Test

e2e/harmony/global-virtual-store.e2e.ts — a component or env that requires a workspace-root direct dependency it does not declare, asserted to resolve under the global virtual store.


Second gap: TypeScript type resolution has no bridge at all

Everything above is about require/import at runtime. The same missing ancestor breaks tsc, and there the bridge does not exist in any form — NODE_PATH and the ESM loader are invisible to the TypeScript resolver.

Symptom

A .d.ts in a store slot cannot walk up to the workspace's hoisted @types. Types do not error out visibly; they resolve to a different copy or collapse to unknown, and the failure surfaces as prop errors in whatever source the type graph reaches:

components/ui/code-compare/code-compare-editor-settings/code-compare-editor-settings.tsx(42,10):
error TS2322: Type '{ children: Element; className: string; checked: boolean; value: string; onInputChanged: () => void; }'
is not assignable to type 'IntrinsicAttributes & CheckboxLabelProps'.
  Property 'children' does not exist on type 'IntrinsicAttributes & CheckboxLabelProps'.

This repo already patches it by hand for its own tsc, in tsconfig.json:

// Under pnpm's global virtual store, a published package's .d.ts can no longer reach the
// workspace's hoisted @types by walking up from its store slot (project-locally the walk
// out of node_modules/.pnpm found them). Pin the react typings to the workspace copies so
// type resolution matches the runtime NODE_PATH bridge.
"paths": {
  "react": ["./node_modules/@types/react"],
  "react-dom": ["./node_modules/@types/react-dom"],
  "@teambit/*": ["./node_modules/@teambit/*"]
}

That pin covers exactly two packages, in one hand-maintained file, for one workspace.

Reproduction

Nothing but the pin separates working from broken, on the enable-gvs branch of this repo:

$ npx tsc --noEmit                       # as committed
0 errors

$ npx tsc --noEmit -p tsconfig.nopin.json   # same file, react/react-dom entries removed
components/ui/code-compare/code-compare-editor-settings/code-compare-editor-settings.tsx(42,10): error TS2322: ...

The build path has no pin, so it fails where the workspace succeeds. e2e/harmony/extensions-config-diff.e2e.ts fails in its before hook: bit tag --build runs teambit.compilation/compiler:TSCompiler for env teambit.harmony/aspect over my-scope/ext1, whose @teambit/* .d.ts files reach the repo's UI component sources, which reach for react typings from a store slot and get the wrong copy. Every children/className prop then fails to type-check.

Reproduced locally on enable-gvs, matching CI exactly — same hook, same task, same component, same first error, same total:

$ npm_config_bit_bin=bbit npx mocha --require ./babel-register --exit \
    e2e/harmony/extensions-config-diff.e2e.ts

  1) extensions config diff
       "before all" hook in "extensions config diff":
     Error: Command failed: bbit tag   --build
Failed task 1: "teambit.compilation/compiler:TSCompiler" of env "teambit.harmony/aspect"
component: my-scope/ext1@0.0.1
...
Found 188 errors in 1 components
✖ Total 15 tasks. 14 succeeded. 1 failed. 0 skipped. Total errors: 188.

The 188 are 47 distinct errors across 27 files, every file inside the workspace, the set repeated as the build prints it through the task result, the BitError, and the wrapping command failure.

Proposed fix

Have the TypeScript compiler inject the type-resolution equivalent of the runtime bridge — paths / typeRoots pointing at the workspace's node_modules and its hoisted directory — whenever the workspace is on the global virtual store, so a capsule build resolves types the way the bridged process resolves requires. Deriving it from the same layout check (isGlobalVirtualStoreLayout) keeps the two halves from drifting.

With that in place, the hand-written paths block in this repo's tsconfig.json should be reviewed: it exists only because the resolver had no bridge, and it covers a set of packages that was never meant to be exhaustive.

Test

e2e/harmony/extensions-config-diff.e2e.ts already fails on the enable-gvs branch and passes once types resolve. A dedicated case in e2e/harmony/global-virtual-store.e2e.ts — building a component whose type graph reaches a store slot — would pin the behaviour where the other global-virtual-store cases live.

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