Until pnpm 11.27.0, the resolver kept one manifest object per resolved name@version in its metadata cache and passed that object straight into your readPackage hook. The documented pnpmfile example tells you to edit it in place and return it. Following the documentation wrote into the resolver's cache, and the next dependent that resolved to the same version inherited your edit whether your condition matched it or not.
The short version: pnpm PR #14014, merged 9 September 2026 and shipped in 11.27.0, adds a shallow copy of the manifest before the hook sees it. If you run a .pnpmfile.cjs or .pnpmfile.mjs that assigns to pkg.*, upgrade, then rewrite the hook to return a new object instead of mutating the argument. The copy covers dependency and peer fields only, so edits to pkg.scripts, pkg.bin or pkg.engines are still writes into an object you do not own.
One object, many dependents
The aliasing lives in resolveDependencies.ts. When several dependents resolve to the same package version, pnpm hands each of them the identical manifest object from its metadata cache. Your hook mutation is never scoped to the dependent being processed; it lands in cache and stays there for the rest of the install.
The fix is a helper named copyResolvedManifest, added in pnpm11/installing/deps-resolver/src/resolveDependencies.ts and applied before the manifest reaches readPackageHook. That path matters. The change landed in the v11 tree of the monorepo, so anyone who has already moved to the 12.x Rust CLI should confirm the equivalent rather than assume it carried over. The pnpm 12 migration has enough surface of its own that assumption is expensive, as the twelve traps worth checking before a pnpm 12 CI upgrade covers. The reproduction at the end of this piece answers the question on any version without reading source.
This sits underneath a practice most platform teams now depend on. readPackage and packageExtensions are how you pin a transitive dependency away from a bad release, repair a missing peer, or force a resolution upstream will not ship. Those are supply-chain controls written as JavaScript, and they were running against shared mutable state.
What the 11.27.0 release note leaves out
The release note describes the bug as a hook that "no longer changes what a later install in the same command resolves." The changeset attached to the PR is broader and more accurate: "Fixed manifest edits leaking between dependencies that resolve to the same package version."
Take the second framing seriously, because it removes the precondition the first one implies. You do not need two installs. One install with two dependents on the same resolved version is enough, which is the ordinary shape of a monorepo: dozens of dependents collapsing onto one hoisted name@version, one object in the cache. A team reading only the release note will conclude they are unaffected because they run a single pnpm install in CI.
Two symptoms that read as flaky CI
The loud symptom is a lockfile that stopped being a pure function of package.json, the registry and the pnpmfile. Resolution order became an input. Two runners that walk the graph in a different order can write different pnpm-lock.yaml files, which shows up downstream as a --frozen-lockfile failure nobody can reproduce locally, gets labelled flake, and gets a retry. It joins a small family of pnpm failures that look environmental and are not, alongside the four causes behind ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR.
The quiet symptom is worse, and it is the one I would go looking for first. A conditional hook that inspects pkg.dependencies before deciding whether to act can read a value some other dependent already mutated, take the wrong branch, and skip the package the rule exists for. Over-application is visible in a lockfile diff. Under-application produces a control that silently no-ops on the single dependency it was written to constrain, and the review that approved that control does not run again.
There is a third artifact riding the same object. The changeset names it: a deprecated notice read from the lockfile was written into the cached manifest and reused for the next dependent, producing a deprecation warning attributed to a package that is not deprecated in that position. Chasing that one through a registry and a lockfile burns an afternoon.
Why is the copy deliberately shallow?
The PR is explicit that the copy is "deliberately shallow and limited to the fields those writes reach": dependency and peer fields, with a follow-up commit extending it to copy each peerDependenciesMeta entry. The justification is cost. The PR cites roughly 0.1µs for the shallow copy against roughly 37µs per manifest for a deep clone, which on a large tree is the difference between free and noticeable.
I think that tradeoff is correct, and it also defines your remaining exposure precisely. A hook that writes to pkg.scripts, pkg.bin, pkg.engines, or anything nested below a field outside the copied set is still mutating a shared object on 11.27.0. The version number is not the condition for safety. The absence of pkg.* assignments in your pnpmfile is.
The docs still teach the unsafe pattern
The example on pnpm.io/pnpmfile mutates in place (pkg.dependencies.baz = '1.2.3') and returns pkg. The page warns that mutations "will affect what gets resolved in the lockfile" and that you may need to delete pnpm-lock.yaml. It says nothing about aliasing. Anything trained on that page, a coding model included, will hand you the same shape, which is how the pattern spread in the first place.
The same page already documents a limit that a lot of teams have misread. Deleting pkg.scripts in readPackage does not stop pnpm building the package, because pnpm reads the real package.json out of the archive at build time. Install-script suppression belongs in pnpm's trust and build-approval settings. If your threat model for postinstall execution is a delete pkg.scripts line in a pnpmfile, that control has never fired, and the npm side of the same argument is worth reading in what breaks after npm 12 CI goes green.
Same object problem, three surfaces
Resolution is where this was found, not where it ends. Open PR #14932 reports pnpm update and pnpm audit --fix=update writing hook-injected dependencies into the on-disk package.json, because the same edits were applied both to the effective manifest used for resolution and to the original manifest written back to disk. A hook meant to be a resolution-time overlay quietly becomes a commit.
Open issue #15025 shows the same family of defect at the fetch layer: resolution keys a cache entry by tarball URL while install derives name@version, so a custom fetcher runs twice for the same package. Three different surfaces, one root pattern of identity confusion between a cached artifact and a per-dependent view of it. That pattern tends to come in clusters, which is why the audit below is worth running even if your lockfile looks clean today.
What to change in your pnpmfile this week
- Upgrade to 11.27.0 or later, then stop mutating regardless. The hook contract is the return value, not the argument. Returning
pkguntouched is fine because no write happens. Everything else gets a spread:
export const hooks = {
readPackage (pkg) {
if (!pkg.dependencies?.baz) return pkg
return { ...pkg, dependencies: { ...pkg.dependencies, baz: '1.2.3' } }
}
}
- Find the writes you already have. Name them rather than eyeballing the file:
grep -nE 'pkg\.[A-Za-z]+(\[[^]]*\])?\s*=|delete pkg\.' .pnpmfile.cjs .pnpmfile.mjs 2>/dev/null
Any hit outside dependency and peer fields is still unprotected on 11.27.0.
- Separate an aliasing leak from an ordinary resolution change with a two-run diff:
cp pnpm-lock.yaml /tmp/lock.base
pnpm install --lockfile-only --ignore-pnpmfile && cp pnpm-lock.yaml /tmp/lock.nohook
git checkout pnpm-lock.yaml
pnpm install --lockfile-only && diff -u /tmp/lock.base pnpm-lock.yaml
A diff that appears only with the hook enabled, on a package your condition does not match, is the leak. A diff that survives --ignore-pnpmfile is a normal resolution change and needs no pnpmfile work. For the deprecation ghost, re-run against a throwaway store with pnpm install --store-dir "$(mktemp -d)" and see whether the warning survives a cache holding no lockfile-sourced notice.
- Gate it in CI instead of in review.
pnpm install --frozen-lockfilefollowed bygit diff --exit-code pnpm-lock.yamlconverts order-dependent resolution into a failing job with a name, rather than an intermittent install error three steps later. Checkgit diff --exit-code package.jsonin the same step while #14932 is open. - Retire the extra job on a condition, not a date. Drop it when the grep in step 2 returns nothing, meaning your pnpmfile performs no assignment to
pkg.*at all. As of 18 September 2026, upgrading alone does not get you there.
FAQ
Is returning pkg unchanged from readPackage safe? Yes. The hazard is the write, not the return. A hook that inspects the manifest and returns the same object without assigning to it never touches the resolver's cached copy, on any version.
Does pnpm 11.27.0 cover edits to pkg.scripts? No. PR #14014 states the copy is deliberately shallow and limited to the fields the known writes reach, meaning dependency and peer fields plus each peerDependenciesMeta entry. Edits to scripts, bin, engines or anything nested under an uncopied field still mutate a shared object.
Why does my pnpm lockfile differ between CI runners? If you run a pnpmfile that mutates manifests on pnpm older than 11.27.0, resolution order is an input to the result, so two runners can legitimately produce different lockfiles from identical inputs. Use the --ignore-pnpmfile comparison above to confirm before blaming the runner image.
Can readPackage block a package's install scripts? No. pnpm reads the real package.json from the package archive at build time, so deleting pkg.scripts in the hook changes resolution metadata and nothing about what runs. Use pnpm's trust and build-approval settings for that, per pnpm.io/pnpmfile.
Comments
Be the first to comment.