perf: skip resolution when only pnpm-lock.yaml is missing

When pnpm-lock.yaml is absent but node_modules/.pnpm/lock.yaml exists and still
satisfies the manifest, reuse the materialized snapshot to regenerate the
wanted lockfile instead of walking the registry to rebuild it. Closes the
cache+node_modules variation gap in the vlt.sh benchmarks for the pnpm CLI
side; the pacquet port is tracked separately at #11993.

`--frozen-lockfile` still fails when pnpm-lock.yaml is absent: the regenerated
file must be committed, so failing loudly is the correct behavior for CI.
This commit is contained in:
Zoltan Kochan
2026-05-27 21:29:44 +02:00
parent 72d997cc34
commit 8a2146b7be
5 changed files with 106 additions and 5 deletions

View File

@@ -0,0 +1,9 @@
---
"@pnpm/installing.deps-installer": patch
"@pnpm/installing.context": patch
"pnpm": patch
---
Skip dependency re-resolution when `pnpm-lock.yaml` is missing but `node_modules/.pnpm/lock.yaml` exists and still satisfies the manifest. `pnpm install` now reuses the materialized snapshot to regenerate `pnpm-lock.yaml` instead of walking the registry to rebuild it from scratch, turning the cache+node_modules variation into a near-no-op for users who deleted the lockfile but kept the install [#11993](https://github.com/pnpm/pnpm/issues/11993).
`--frozen-lockfile` still refuses to proceed when `pnpm-lock.yaml` is absent — the regenerated lockfile must be committed, so failing loudly is the correct behavior for CI.

View File

@@ -36,6 +36,7 @@ export interface PnpmContext {
existsCurrentLockfile: boolean
existsWantedLockfile: boolean
existsNonEmptyWantedLockfile: boolean
hasUsableLockfile: boolean
extraBinPaths: string[]
/** Affected by existing modules directory, if it exists. */
extraNodePaths: string[]
@@ -207,6 +208,7 @@ export interface PnpmSingleContext {
existsCurrentLockfile: boolean
existsWantedLockfile: boolean
existsNonEmptyWantedLockfile: boolean
hasUsableLockfile: boolean
/** Affected by existing modules directory, if it exists. */
extraBinPaths: string[]
extraNodePaths: string[]

View File

@@ -20,6 +20,7 @@ export interface PnpmContext {
existsCurrentLockfile: boolean
existsWantedLockfile: boolean
existsNonEmptyWantedLockfile: boolean
hasUsableLockfile: boolean
wantedLockfile: LockfileObject
}
@@ -48,6 +49,7 @@ export async function readLockfiles (
existsCurrentLockfile: boolean
existsWantedLockfile: boolean
existsNonEmptyWantedLockfile: boolean
hasUsableLockfile: boolean
wantedLockfile: LockfileObject
wantedLockfileIsModified: boolean
lockfileHadConflicts: boolean
@@ -122,10 +124,14 @@ export async function readLockfiles (
}
}
}
const existsWantedLockfile = files[0] != null
const existsCurrentLockfile = files[1] != null
const wantedLockfile = files[0] ??
(currentLockfile && clone(currentLockfile)) ??
createLockfileObject(importerIds, sopts)
let wantedLockfileIsModified = false
// Cloning the current lockfile means the disk copy of the wanted lockfile is
// stale, so flag it for rewriting after the install completes.
let wantedLockfileIsModified = !existsWantedLockfile && existsCurrentLockfile
for (const importerId of importerIds) {
if (!wantedLockfile.importers[importerId]) {
wantedLockfileIsModified = true
@@ -134,13 +140,13 @@ export async function readLockfiles (
}
}
}
const existsWantedLockfile = files[0] != null
return {
currentLockfile,
currentLockfileIsUpToDate: equals(currentLockfile, wantedLockfile),
existsCurrentLockfile: files[1] != null,
existsCurrentLockfile,
existsWantedLockfile,
existsNonEmptyWantedLockfile: existsWantedLockfile && !isEmptyLockfile(wantedLockfile),
hasUsableLockfile: !isEmptyLockfile(wantedLockfile),
wantedLockfile,
wantedLockfileIsModified,
lockfileHadConflicts,

View File

@@ -909,7 +909,7 @@ export async function mutateModules (
!needsFullResolution &&
opts.preferFrozenLockfile &&
(!opts.pruneLockfileImporters || Object.keys(ctx.wantedLockfile.importers).length === Object.keys(ctx.projects).length) &&
ctx.existsNonEmptyWantedLockfile &&
ctx.hasUsableLockfile &&
ctx.wantedLockfile.lockfileVersion === LOCKFILE_VERSION &&
await allProjectsAreUpToDate(Object.values(ctx.projects), {
catalogs: opts.catalogs,
@@ -939,6 +939,17 @@ Note that in CI environments, this setting is enabled by default.`,
)
}
if (!opts.ignorePackageManifest) {
// `--frozen-lockfile` (the CI default) means "fail if pnpm-lock.yaml is
// out of sync." Treat its absence as a sync failure even when the
// synthesized snapshot from node_modules/.pnpm/lock.yaml would satisfy
// the manifest — the developer needs to commit the regenerated file.
if (frozenLockfile && !ctx.existsWantedLockfile &&
Object.values(ctx.projects).some((project) => pkgHasDependencies(project.manifest))) {
throw new PnpmError('NO_LOCKFILE',
`Cannot install with "frozen-lockfile" because ${WANTED_LOCKFILE} is absent`, {
hint: 'Note that in CI environments this setting is true by default. If you still need to run install in such cases, use "pnpm install --no-frozen-lockfile"',
})
}
const _satisfiesPackageManifest = satisfiesPackageManifest.bind(null, {
autoInstallPeers: opts.autoInstallPeers,
excludeLinksFromLockfile: opts.excludeLinksFromLockfile,
@@ -972,7 +983,7 @@ Note that in CI environments, this setting is enabled by default.`,
ignoredBuilds: undefined,
}
}
if (!ctx.existsNonEmptyWantedLockfile) {
if (!ctx.hasUsableLockfile) {
if (Object.values(ctx.projects).some((project) => pkgHasDependencies(project.manifest))) {
throw new Error(`Headless installation requires a ${WANTED_LOCKFILE} file`)
}

View File

@@ -1,3 +1,4 @@
import fs from 'node:fs'
import path from 'node:path'
import { expect, jest, test } from '@jest/globals'
@@ -220,6 +221,78 @@ test(`prefer-frozen-lockfile+hoistPattern: should prefer headless installation w
project.has('.pnpm/node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep')
})
test(`prefer-frozen-lockfile: should reuse node_modules/.pnpm/lock.yaml when ${WANTED_LOCKFILE} is missing and the snapshot satisfies package.json`, async () => {
const project = prepareEmpty()
const { updatedManifest: manifest } = await install({
dependencies: {
'is-positive': '^3.0.0',
},
}, testDefaults())
project.has('is-positive')
const wantedLockfilePath = path.resolve(WANTED_LOCKFILE)
const lockfileBefore = fs.readFileSync(wantedLockfilePath, 'utf8')
fs.rmSync(wantedLockfilePath)
const reporter = jest.fn()
await install(manifest, testDefaults({ reporter, preferFrozenLockfile: true }))
expect(reporter).toHaveBeenCalledWith(expect.objectContaining({
level: 'info',
message: 'Lockfile is up to date, resolution step is skipped',
name: 'pnpm',
}))
expect(fs.existsSync(wantedLockfilePath)).toBe(true)
expect(fs.readFileSync(wantedLockfilePath, 'utf8')).toBe(lockfileBefore)
project.has('is-positive')
})
test(`prefer-frozen-lockfile: should re-resolve when ${WANTED_LOCKFILE} is missing and node_modules/.pnpm/lock.yaml does not satisfy package.json`, async () => {
const project = prepareEmpty()
await install({
dependencies: {
'is-positive': '^3.0.0',
},
}, testDefaults())
fs.rmSync(path.resolve(WANTED_LOCKFILE))
const reporter = jest.fn()
await install({
dependencies: {
'is-negative': '1.0.0',
},
}, testDefaults({ reporter, preferFrozenLockfile: true }))
expect(reporter).not.toHaveBeenCalledWith(expect.objectContaining({
level: 'info',
message: 'Lockfile is up to date, resolution step is skipped',
name: 'pnpm',
}))
project.has('is-negative')
})
test(`frozen-lockfile: should fail if ${WANTED_LOCKFILE} is missing even when node_modules/.pnpm/lock.yaml satisfies package.json`, async () => {
prepareEmpty()
const { updatedManifest: manifest } = await install({
dependencies: {
'is-positive': '^3.0.0',
},
}, testDefaults())
fs.rmSync(path.resolve(WANTED_LOCKFILE))
await expect(
install(manifest, testDefaults({ frozenLockfile: true }))
).rejects.toThrow(`Cannot install with "frozen-lockfile" because ${WANTED_LOCKFILE} is absent`)
})
test('prefer-frozen-lockfile: should prefer frozen-lockfile when package has linked dependency', async () => {
const projects = preparePackages([
{