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:
node_modules/.pnpm/node_modules — the hoisted dir, from the node_modules/.pnpm ancestor
<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:
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.
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 ownnode_modules, so any phantom require of a direct dependency of the workspace root fails.Background
ensureHoistedDependencyResolution(hoisted-resolution-bridge.ts:205) puts one directory onNODE_PATH(plus the ESM loader):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:node_modules/.pnpm/node_modules— the hoisted dir, from thenode_modules/.pnpmancestor<workspaceRoot>/node_modules— from the workspace root ancestorUnder 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: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 theenable-gvsbranch), mocha requiring its reporter:mocha-multi-reportersis 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'snode_modules.Proposed fix
Add
<workspaceRoot>/node_modulesto 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 throughNODE_PATH, and the ESM loader iteratesextraNodePaths— so this is a matter of whatensureHoistedDependencyResolutionputs on the path.Points worth deciding in review:
ensureSelfInstallationBridgeuses the same function against the installation root, where<root>/node_modulesis 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.createRequireinside each entry and relies on node not appending/node_modulesto a directory already namednode_modules— the comment at hoisted-resolution-bridge.ts:70 notes this holds exactly for entries namednode_modules. The new entry is also namednode_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/importat runtime. The same missing ancestor breakstsc, and there the bridge does not exist in any form —NODE_PATHand the ESM loader are invisible to the TypeScript resolver.Symptom
A
.d.tsin 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 tounknown, and the failure surfaces as prop errors in whatever source the type graph reaches:This repo already patches it by hand for its own
tsc, in tsconfig.json: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-gvsbranch of this repo:The build path has no pin, so it fails where the workspace succeeds.
e2e/harmony/extensions-config-diff.e2e.tsfails in itsbeforehook:bit tag --buildrunsteambit.compilation/compiler:TSCompilerfor envteambit.harmony/aspectovermy-scope/ext1, whose@teambit/*.d.tsfiles reach the repo's UI component sources, which reach forreacttypings from a store slot and get the wrong copy. Everychildren/classNameprop then fails to type-check.Reproduced locally on
enable-gvs, matching CI exactly — same hook, same task, same component, same first error, same total: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/typeRootspointing at the workspace'snode_modulesand 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
pathsblock in this repo'stsconfig.jsonshould 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.tsalready fails on theenable-gvsbranch and passes once types resolve. A dedicated case ine2e/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.