Skip to content

fix(dependency-resolver): patch react-env's tsconfig extends for the global virtual store - #10572

Draft
zkochan wants to merge 9 commits into
teambit:masterfrom
zkochan:gvs-react-env-patch
Draft

fix(dependency-resolver): patch react-env's tsconfig extends for the global virtual store#10572
zkochan wants to merge 9 commits into
teambit:masterfrom
zkochan:gvs-react-env-patch

Conversation

@zkochan

@zkochan zkochan commented Aug 5, 2026

Copy link
Copy Markdown
Member

Ships a patch for @teambit/react.react-env's phantom tsconfig dependency under the global virtual store.

Blocked — do not merge. Split out of #10567 so that PR is not held up by this.

The problem

@teambit/react.react-env 1.x ships a tsconfig whose extends names @teambit/react/typescript/tsconfig.json without declaring @teambit/react. That resolved only by TypeScript walking up node_modules into the workspace, which a package in the shared store cannot do — so under the global virtual store bit status reports:

File '@teambit/react/typescript/tsconfig.json' not found.

and is clean without it. NODE_PATH and the ESM loader cannot cover it: TypeScript resolves extends with its own resolver and consults neither. The patch inlines the extended config so no resolution is needed — the only form that works from an arbitrary location in the shared store.

Why it is blocked

react-env 2.x already inlines the config, so the phantom dependency only exists on the 1.x line. That makes the patch key version-sensitive:

key 1.x workspaces 2.x workspaces
@teambit/react.react-env (this PR) applies fails to apply
@teambit/react.react-env@1 applies ERR_PNPM_UNUSED_PATCH

pnpm fails an install when a configured patch matches no installed package (crates/patching/src/verify.rs, verify_patches). allow_unused_patches defaults to false and is not exposed through @pnpm/napi12.0.0-rc.0 accepts patchedDependencies only.

So neither key is safe today. This repo itself resolves only react-env 2.0.3, so merging as-is would break its own global-virtual-store installs.

Unblocking

Once a napi release exposes allowUnusedPatches:

  1. change the key to @teambit/react.react-env@1
  2. pass allowUnusedPatches so workspaces on 2.x do not fail

Then this is mergeable.

Notes

  • Built on top of feat(dependency-resolver): opt-in support for pnpm's global virtual store #10567 (gvs) — it depends on enableGlobalVirtualStore(). Until that lands, the diff here shows its commits too; only the last commit belongs to this PR.
  • The patch-shipping mechanism works as-is: shouldCopyNonSupportedFiles copies patches/*.patch into dist, same as the existing fixtures/cafile.txt. No build changes needed.
  • Verified applying on a workspace resolving react-env 1.0.97 / 1.0.109 / 1.0.131 / 1.0.132 — all four take the patch, which is why the key is unversioned here.

zkochan and others added 9 commits August 5, 2026 22:29
`_calculateFinalFileName` stripped the extension from the whole path, so
any dot in a parent directory was treated as the extension:
`bit doctor --archive /tmp/my.dir/report` wrote `/tmp/my.tar` instead of
`/tmp/my.dir/report.tar`.

Strip the extension from the last path segment only. Unrelated to the
rest of this branch - it surfaced because the e2e temp dir contains a
dot, which is why `doctor-infra.e2e.ts` fails locally but passes on CI.
…tore

With `enableGlobalVirtualStore` on, dependency directories are created
once in a store shared by every workspace and capsule on the machine
instead of being re-created inside each `node_modules/.pnpm`. On real
workspaces that collapses the project-local virtual store from 20832 to
529 entries (and 4017 to 258), leaving only the injected components.

Off by default. Enable per workspace under
`teambit.dependencies/dependency-resolver`, machine-wide with
`bit config set enable_global_virtual_store true`, or per run with
`BIT_ENABLE_GLOBAL_VIRTUAL_STORE` (which is how the e2e suite runs the
whole matrix under it).

Making it behave identically to the project-local layout needed four
fixes:

- The core aspects are phantom dependencies of every published env and
  aspect. Project-locally a package in `node_modules/.pnpm` resolves
  them by walking up into the workspace's root `node_modules`; nothing
  in a shared store can walk up into a workspace, so every env failed to
  load. Mirror the installation's core-aspect links at the root of the
  store, which every slot in it can reach, and key that store per bit
  installation so two of them cannot shadow each other's aspects.
- A workspace that authors the core aspects (the bit repo) bridges them
  into the hoisted store only until they compile, then removes the
  bridge. That is right project-locally and fatal from a shared store,
  so under it the bridge stays, repointed at the compiled workspace copy.
- `getInjectedDirs` reports an injected copy absolute when it cannot be
  made relative to the lockfile dir - which under a shared store is
  always. `compiler.task.ts` joined it onto the capsule root regardless,
  hard-linking build artifacts into a path that does not exist.
- Capsules stay on the project-local store. A capsule is a self-contained
  build sandbox and TypeScript's declaration emit depends on that: it
  names an inferred type through the package that declares it, which it
  can only do while that package is inside the capsule. Moving env and
  aspect packages out made `bit build` of any custom env fail with
  TS2742.

Also adds `patchedDependencies` and `packageExtensions` passthrough.
`packageExtensions` is not optional under a shared store: it is the only
way to satisfy a phantom dependency of a published package, since the
package now lives outside the project and can no longer reach whatever
the project hoisted.

`bit deps diagnose` and the prune step read the current lockfile instead
of assuming `node_modules/.pnpm` holds the packages, and the global store
is skipped for `nodeLinker: hoisted`, which uses no virtual store.
Three suites hand-wrote paths that only exist in the project-local
virtual store, so they broke under the global one even though the
behaviour they check was fine:

- `install.e2e` and `node-linker.e2e` asserted on `node_modules/.pnpm`
  directly. New `helper.fs.getVirtualStoreDirNames()` reads the current
  lockfile when the directory holds no package dirs, which yields the
  same names either way.
- `dependency-resolver.e2e` verified dependency overrides by reading
  `node_modules/.pnpm/<depPath>/node_modules/<dep>/package.json`. New
  `helper.fs.readPackageJsonOfChain(['rimraf','glob','inflight','once'])`
  walks the chain the way Node resolves it, so it is correct under the
  project-local, global and hoisted layouts alike.

Adds `global-virtual-store.e2e.ts` covering the store itself: deps are
not created in the workspace, they resolve out of the shared store, the
current lockfile still records them, the component loads and compiles,
and a patched dependency is applied to the shared copy.
Documents `enableGlobalVirtualStore`, `globalVirtualStoreDir` and
`patchedDependencies`, and adds the script that installs a locally built
pnpm engine over the published `@pnpm/napi` - needed until a release
carries the options this branch passes (pnpm/pnpm#13648).
`my-react-env.bit-env.ts` opened with `// @bit-no-check`, which turns off
Bit's dependency detection for the file. Its imports - notably
`@teambit/react.react-env` - therefore never reached the component's
generated manifest, so the env only resolved them by reaching packages
the workspace had hoisted.

That is a phantom dependency, and it stops working the moment the
package is not inside the project any more: under the global virtual
store the env fails to load with `Cannot find module
'@teambit/react.react-env'`. Nothing in this fixture needs dependency
detection off - `@ts-nocheck`, which is what the tsconfig scenario
actually relies on, stays.

None of the real env components in the workspaces this was checked
against carry `@bit-no-check`, and they resolve correctly either way.
… global virtual store

The core aspects are phantom dependencies of every published env: required
without being declared, provided by the running bit installation. Under the
project-local virtual store an env resolves them by walking up out of
node_modules/.pnpm into the workspace root. Under the global virtual store the
env lives in the shared store with no workspace above it, so that fails.

Bit worked around this by mirroring the core aspects at the root of the virtual
store, which forced a <storeDir>/bit-links/<installationId> root private to one
bit installation: no slot reuse across bit versions or with other pnpm
projects, a full re-materialization of ~275k directories on every bit upgrade,
and nothing to prune stale roots.

Use pnpm's documented mechanism for hoisted dependencies in this layout
instead. The privately hoisted directory stays project-local at
node_modules/.pnpm/node_modules even under the global virtual store, and is
reached from a store slot via NODE_PATH. Link the core aspects there and put it
on NODE_PATH during bootstrap - pnpm does that for the shims it writes, but bit
runs from bvm and loads aspects in its own process.

Nothing is written inside the store, so pnpm's shared <storeDir>/links can be
used: slots are reused across bit versions and with every other pnpm project,
upgrades stay incremental, and pnpm store prune accounts for them.

Node ignores NODE_PATH for ESM. Core aspects and published env dists are
CommonJS today; @pnpm/plugin-esm-node-path is the escape hatch if that changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… in ESM too

NODE_PATH covers only CommonJS - Node ignores it for ESM entirely. Bit supports
ESM components, so an ESM package in the shared store could not reach the
hoisted directory at all, leaving the workaround covering only half the problem.

Register an ESM loader whose resolve hook retries a failed bare specifier
against the NODE_PATH entries. Registered in bit's own process via
module.register, and exported through NODE_OPTIONS so the processes bit spawns -
env build steps, app servers, test workers - inherit it, since they resolve
their own dependencies and hit the same wall.

The loader and the --import registration are adapted from pnpm's
@pnpm/plugin-esm-node-path (MIT), inlined as a data URL so there is no file to
ship and no install step to depend on. Skipped when module.register is missing
(Node < 20.6); the CommonJS half still applies there.

Also generalize the surrounding comments: this is about phantom dependencies as
a class, not core aspects specifically. The core aspects are the case that
motivated it, but any under-declared package resolves the same way, which is why
the whole hoisted directory goes on the path rather than a hand-picked list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The global virtual store work needs two engine changes that are now in a
released build: the napi surface for `enableGlobalVirtualStore`,
`globalVirtualStoreDir`, `packageExtensions` and `patchedDependencies`
(pnpm/pnpm#13648), and the fix for `link:` dependencies vanishing from
virtual-store slots (pnpm/pnpm#13658).

Verified on the released engine: a global-virtual-store install of the
opus-5 workspace reuses all 30,766 existing slots (creates none), leaves
the project-local `node_modules/.pnpm` empty, and still applies the
react-env tsconfig patch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…global virtual store

@teambit/react.react-env 1.x ships a tsconfig whose `extends` names
@teambit/react/typescript/tsconfig.json without declaring @teambit/react - a
phantom dependency in a published package. It resolved only by TypeScript
walking up node_modules into the workspace, which a package in the shared store
cannot do, so `bit status` reported "File '@teambit/react/typescript/
tsconfig.json' not found." under the global virtual store and was clean without
it.

NODE_PATH and the ESM loader cannot cover this: TypeScript resolves `extends`
with its own resolver and never consults either. Ship a patch that inlines the
extended config so no resolution is needed - the only form that works from any
location. Declaring the dependency instead would install a registry copy of a
core aspect, duplicating what has to stay a single host-provided instance.

BLOCKED, do not merge yet. react-env 2.x already inlines the config, so the
phantom dependency only exists on the 1.x line. The key therefore has to be
scoped (`@teambit/react.react-env@1`), but pnpm fails an install with
ERR_PNPM_UNUSED_PATCH when a configured patch matches no installed package
(crates/patching/src/verify.rs, `allow_unused_patches` defaults to false), and
that setting is not exposed through @pnpm/napi - 12.0.0-rc.0 accepts
`patchedDependencies` only. Keying it bare, as this commit does, makes it fail
to apply on 2.x instead.

Waiting on a napi release exposing allowUnusedPatches. Then: change the key to
`@teambit/react.react-env@1` and pass allowUnusedPatches so workspaces on 2.x,
including this repo, do not fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zkochan added a commit to pnpm/pnpm that referenced this pull request Aug 6, 2026
pnpm fails an install when a configured patch matches no installed
package (ERR_PNPM_UNUSED_PATCH). The CLI lets a workspace opt out via
allowUnusedPatches in pnpm-workspace.yaml, but the binding accepted
patchedDependencies only, so an embedder that ships its own patches had
no way to reach the setting.

Threads the flag through InstallOptions -> ConfigOverlay ->
Config::allow_unused_patches, which the fresh-lockfile path already reads
when it calls verify_patches. No engine-side change is needed; this is
purely the missing binding surface.

Bit needs it to ship a patch keyed to a version range
(`@teambit/react.react-env@1`) from its own config: workspaces that
resolve 2.x match no key and would otherwise fail the install. See
teambit/bit#10572.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant