pnpm version
12.7.0 (11.28.0 behaves correctly)
Reproduction steps
mkdir /tmp/refresh-repro && cd /tmp/refresh-repro
build () {
rm -rf stage && mkdir -p stage/package
echo '{"name":"dep-a","version":"1.0.0"}' > stage/package/package.json
echo "$1" > stage/package/payload.txt
( cd stage && COPYFILE_DISABLE=1 tar -czf ../served.tgz package )
}
echo '{"name":"repro","version":"1.0.0","dependencies":{"dep-a":"1.0.0"}}' > package.json
echo 'optimisticRepeatInstall: false' > pnpm-workspace.yaml
cat > .pnpmfile.cjs <<'EOF'
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const served = path.join(__dirname, 'served.tgz')
const integrity = () => 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(served)).digest('base64')
module.exports = {
resolvers: [{
canResolve: (wanted) => wanted.alias === 'dep-a',
resolve: async () => ({
id: 'dep-a@1.0.0',
manifest: { name: 'dep-a', version: '1.0.0' },
resolution: { type: 'custom:repro', integrity: integrity() },
}),
shouldRefreshResolution: (depPath, snapshot) => snapshot.resolution.integrity !== integrity(),
}],
fetchers: [{
canFetch: (pkgId, resolution) => resolution.type === 'custom:repro',
fetch: (cafs, resolution, opts, fetchers) =>
fetchers.localTarball(cafs, { tarball: 'file:' + served, integrity: integrity() }, opts),
}],
}
EOF
build V1
pnpm install
cat node_modules/dep-a/payload.txt # V1
build V2 # same name@version, new bytes
pnpm install
cat node_modules/dep-a/payload.txt # V1 on 12.7.0, V2 on 11.28.0
grep integrity pnpm-lock.yaml # new integrity on both
Describe the Bug
On the second install, shouldRefreshResolution returns true and pnpm writes the new integrity to the lockfile, but it never calls the custom fetcher and leaves the old files in node_modules. It then prints "Already up to date". Because the lockfile now matches the new tarball, later installs (including --frozen-lockfile) see nothing to fix. Only --force or deleting node_modules recovers.
Cause: LockfileResolution::integrity() returns None for Custom resolutions (crates/lockfile/src/resolution.rs). Both checks that decide whether to reuse a virtual-store slot compare integrities through it: current_entry_unchanged in create_virtual_store/snapshot_plan.rs and package_content_changed in create_virtual_store/cache_keys.rs. None == None counts as unchanged, so the slot is kept and not re-imported. pnpm 11's isIntegrityEqual (deps/graph-builder/src/lockfileToDepGraph.ts) reads the integrity field of any resolution, custom ones included.
#15030 fixed the --force path for the same scenario with a tarball resolution. That issue's repro doesn't hit this bug because tarball resolutions expose their integrity.
Expected Behavior
When a custom resolution's integrity changes, pnpm fetches it and re-imports the slot on a plain install, as pnpm 11 does. Built-in verification can keep treating custom integrity as opaque. Only the reuse checks need to compare it.
Related: the hook is skipped silently by default
The repro sets optimisticRepeatInstall: false. With the default (true), the repeat-install fast path in install/run/fast_path.rs returns "Already up to date" before shouldRefreshResolution runs, so the hook cannot fire unless a manifest or the pnpmfile changed. pnpm 11 has the same shortcut but warns: "shouldRefreshResolution hooks were skipped because optimisticRepeatInstall is enabled." pnpm 12 prints no warning.
Node.js version
24.21.0
Operating System
macOS
Written by an agent (Claude Code, claude-opus-5-5).
pnpm version
12.7.0 (11.28.0 behaves correctly)
Reproduction steps
Describe the Bug
On the second install,
shouldRefreshResolutionreturns true and pnpm writes the new integrity to the lockfile, but it never calls the custom fetcher and leaves the old files innode_modules. It then prints "Already up to date". Because the lockfile now matches the new tarball, later installs (including--frozen-lockfile) see nothing to fix. Only--forceor deletingnode_modulesrecovers.Cause:
LockfileResolution::integrity()returnsNoneforCustomresolutions (crates/lockfile/src/resolution.rs). Both checks that decide whether to reuse a virtual-store slot compare integrities through it:current_entry_unchangedincreate_virtual_store/snapshot_plan.rsandpackage_content_changedincreate_virtual_store/cache_keys.rs.None == Nonecounts as unchanged, so the slot is kept and not re-imported. pnpm 11'sisIntegrityEqual(deps/graph-builder/src/lockfileToDepGraph.ts) reads theintegrityfield of any resolution, custom ones included.#15030 fixed the
--forcepath for the same scenario with a tarball resolution. That issue's repro doesn't hit this bug because tarball resolutions expose their integrity.Expected Behavior
When a custom resolution's
integritychanges, pnpm fetches it and re-imports the slot on a plain install, as pnpm 11 does. Built-in verification can keep treating custom integrity as opaque. Only the reuse checks need to compare it.Related: the hook is skipped silently by default
The repro sets
optimisticRepeatInstall: false. With the default (true), the repeat-install fast path ininstall/run/fast_path.rsreturns "Already up to date" beforeshouldRefreshResolutionruns, so the hook cannot fire unless a manifest or the pnpmfile changed. pnpm 11 has the same shortcut but warns: "shouldRefreshResolution hooks were skipped because optimisticRepeatInstall is enabled." pnpm 12 prints no warning.Node.js version
24.21.0
Operating System
macOS
Written by an agent (Claude Code, claude-opus-5-5).