Repository navigation
Dependency graph: lockfile overwrite on re-import causes version drift #10317
Description
Activity
Test run results
Ran each new `describe` block in isolation via `.only`:
Test Result `importing a component into a workspace that already has an installed component` 2 pass, 1 fail — drift reproduced: after importing a second component, `foo@100.0.0` is missing from `pnpm-lock.yaml` (pnpm re-resolves to the newer `foo@100.1.0`) `re-importing an updated version of an already-imported component` 1 fail — same drift on the update path: `bar@100.0.0` is replaced with `bar@100.1.0` after re-importing comp1 `three components sharing a peer dependency` 3 pass (third assertion documents a newly-surfaced bug, see below) `dev and optional dependencies round-trip through the graph` 3 pass New finding from the 3-component merge
Unexpected behavior surfaced while verifying the 3-way merge. Setup: three components (bar, foo, baz) all consume a package that carries a peer dep on `@pnpm.e2e/abc`. Their tag-time `abc` versions were 1.0.0, 2.0.0, 1.0.0 respectively. After import:
- `abc@2.0.0` is present (highest-wins on the root edge) — correct.
- `abc@1.0.0` is also present.
Root cause: `DependenciesGraph.merge` applies highest-wins to the root edge only. Transitive snapshots from the lower-version graphs (e.g. baz's snapshot of bar, which references `abc@1.0.0`) are carried over verbatim. pnpm then sees a live reference to `abc@1.0.0` and keeps it in the lockfile.
Peer packages don't leak the same way because pnpm reconciles peers across importers at install time. Regular deps do.
Net effect: lockfiles grow with every 3-way merge, and unused older versions of non-peer packages may be downloaded and linked.
Status of the fix in `PnpmPackageManager.dependenciesGraphToLockfile`
Not started. Two gaps now clearly documented in tests: (1) re-import drift, (2) transitive lower-version leakage in 3-way merges.
- added a commit that references this issue
on Apr 20, 2026 Fix landed in #10318 (ready for review)
Both drift paths are fixed. All 20 `e2e/harmony/deps-graph.e2e.ts` cases pass locally.
What changed
- `PnpmPackageManager.dependenciesGraphToLockfile` now reads the existing wanted lockfile and overlays the graph-derived `importers` / `packages` / `snapshots` onto it. Importer merge is per-project / per-dep-type / per-dep-key, which keeps unrelated projects' locked deps intact even though `convertGraphToLockfile` emits a sparse importer entry for every workspace project.
- `Scope.getDependenciesGraphByComponentIds` clones the first loaded graph before merging. Previously it mutated `Version._dependenciesGraph` in place and leaked merged state to subsequent callers of the same component.
Still open, tracked here
- Merge leaves lower-version non-peer transitives in the lockfile (surfaced by the new 3-component test). The root edge upgrades, but snapshots from the lower-version graphs still reference the older version, and pnpm keeps both.
- Coverage not added yet: missing graph Source fallback, `patchedDependencies`, `overrides`, `directory`-type resolutions, specifier drift between tag and import.
- Duplicate root edges accumulated by `merge()` (cosmetic today, but makes the graph grow on every merge).
Summary
The dependency graph stored with each signed component (
dependenciesGraphRefon theVersionobject) is used to regeneratepnpm-lock.yamlon import. The regeneration overwrites the existing workspace lockfile instead of merging into it. BecauseComponentWriterMain.installPackagesGracefullyonly passes the IDs of the components just written toScope.getDependenciesGraphByComponentIds, subsequent imports into an already-populated workspace can silently drift the locked versions of unrelated workspace dependencies.Feature is gated behind the
DEPS_GRAPHfeature toggle androotComponents: true, so it is off by default — but when enabled, the behavior above is surprising.Repro path
BIT_FEATURES=deps-graphand setrootComponents: trueinworkspace.jsonc.comp1(depends onfoo@^100.0.0, lockfile resolves tofoo@100.0.0) andcomp2(depends onbar@^100.0.0).bit import comp1@latest→pnpm-lock.yamlrestored from comp1's graph withfoo@100.0.0locked.foo@100.1.0becomes the latest matching version.bit import comp2@latest→pnpm-lock.yamlis regenerated from comp2's graph only.foois no longer in the lockfile, so pnpm re-resolves it from the manifest specifier and picksfoo@100.1.0.Same class of drift also hits the "pull new version of an already-imported component" path.
Code pointers
scopes/component/component-writer/component-writer.main.runtime.ts:91-114— only passes IDs of newly-written components:```ts
installationError = await this.installPackagesGracefully(
opts.components.map(({ id }) => id), // only the components just written
opts.skipWriteConfigFiles
);
...
dependenciesGraph: await this.workspace.scope.getDependenciesGraphByComponentIds(componentIds),
```
scopes/dependencies/pnpm/pnpm.package-manager.ts:94-104— writes the graph-derived lockfile directly, no read-merge-write:```ts
const lockfile: LockfileFile = await convertGraphToLockfile(dependenciesGraph, { ...opts, resolve });
Object.assign(lockfile, { bit: { restoredFromModel: true } });
const lockfilePath = join(opts.rootDir, 'pnpm-lock.yaml');
await writeLockfileFile(lockfilePath, lockfile);
```
Proposed fix
In
PnpmPackageManager.dependenciesGraphToLockfile, read the existing wanted lockfile first and overlay the graph-derivedpackages,snapshots, and affectedimportersonto it. Graph entries win for the imported component's subtree; everything else preserved. Alternative (heavier): fetch graphs for the whole workspace on every install and merge them before conversion — still doesn't cover workspace-only deps that were never graph-stored, so option A is preferred.Other issues surfaced during audit
components/legacy/scope/scope.ts:745+scopes/scope/objects/models/dependencies-graph.ts:79).getDependenciesGraphByComponentIdsassigns the first loaded graph to `allGraph` and then calls `allGraph.merge(other)`. That graph is the cached `Version._dependenciesGraph`, so the merge mutates the cache in place; subsequent calls starting with the same component observe an already-merged graph.dependencies-graph.ts:99). After smart-merging direct-deps neighbours in place, the method pushes all edges of the other graph including its root edge.findRootEdge()only returns the first match so behavior is correct today, but the list keeps growing on each merge.scopes/dependencies/pnpm/lockfile-deps-graph-converter.ts:304-314). A manifest dep whose specifier doesn't match any root edge by `(name, specifier)` or `name@specifier` is skipped without warning.Test coverage added in advance of the fix
New blocks in `e2e/harmony/deps-graph.e2e.ts`:
Follow-up coverage not yet added