fix: stop install from recreating node_modules after pnpm fetch (#11490)

Closes #11488.

`pnpm fetch` writes forced-empty `hoistPattern: []` and `publicHoistPattern: []` into `.modules.yaml` (because its `virtualStoreOnly` install path skips hoisting). In v10 the follow-up `pnpm install` ignored these unless the user had explicitly set a hoist-pattern in their config. v11's [#11199](https://github.com/pnpm/pnpm/pull/11199) removed that explicit-config gate, so `validateModules` now always sees the empty patterns as a hoist-pattern change and purges `node_modules` — slow on every CI run, and per the bug report sometimes leaves the modules dir in an `ERR_MODULE_NOT_FOUND` state on subsequent runs.

The fix marks `.modules.yaml` with a new `virtualStoreOnly: true` field after a fetch. `validateModules` recognizes this flag as "incomplete install state" and skips the `PUBLIC_HOIST_PATTERN_DIFF` / `HOIST_PATTERN_DIFF` comparisons. The next install then completes the missing post-import linking in place rather than purging. The flag is dropped from `.modules.yaml` once a normal install runs.

A genuine hoist-pattern change (without a fetch in between) still triggers the purge as before — verified manually with `publicHoistPattern` in `pnpm-workspace.yaml`.
This commit is contained in:
Zoltan Kochan
2026-05-06 14:39:40 +02:00
parent 81161d51c6
commit 12313f1ac4
7 changed files with 85 additions and 1 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/installing.modules-yaml": patch
"@pnpm/installing.deps-restorer": patch
"@pnpm/installing.deps-installer": patch
"pnpm": patch
---
Fix `pnpm install` recreating `node_modules` after `pnpm fetch`. `pnpm fetch` records empty `hoistPattern` and `publicHoistPattern` in `.modules.yaml`; since v11 removed the explicit-config gate, the follow-up install treated those as a hoist-pattern change and purged the modules directory. The fetch step now flags the modules manifest with `virtualStoreOnly: true` so the next install skips the hoist-pattern comparison and completes the missing post-import linking in place [#11488](https://github.com/pnpm/pnpm/issues/11488).

View File

@@ -284,6 +284,8 @@ export type InstallCommandOptions = Pick<Config,
| 'frozenLockfile'
| 'global'
| 'globalPnpmfile'
| 'hoistPattern'
| 'publicHoistPattern'
| 'ignorePnpmfile'
| 'ignoreScripts'
| 'injectWorkspacePackages'

View File

@@ -230,6 +230,68 @@ test('fetch populates global virtual store links/', async () => {
expect(entries.length).toBeGreaterThan(0)
})
// Regression test for https://github.com/pnpm/pnpm/issues/11488
// A subsequent install must not purge node_modules just because fetch
// recorded forced-empty hoist patterns in .modules.yaml. The follow-up
// install must also complete the post-import linking that fetch skipped:
// importer symlinks at the project root and hoisting under .pnpm/node_modules.
test('install after fetch completes linking without recreating node_modules', async () => {
const project = prepare({
dependencies: { '@pnpm.e2e/pkg-with-1-dep': '100.0.0' },
})
const storeDir = path.resolve('store')
// Generate the lockfile only — no need for a full install
await install.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
linkWorkspacePackages: true,
lockfileOnly: true,
storeDir,
})
await fetch.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
storeDir,
})
const modulesYamlPath = path.resolve(project.dir(), 'node_modules/.modules.yaml')
const virtualStoreDir = path.resolve(project.dir(), 'node_modules/.pnpm')
const virtualStoreInodeBefore = fs.statSync(virtualStoreDir).ino
// fetch only populates the virtual store — no importer symlink and no
// hoisting yet.
expect(fs.existsSync(path.resolve(project.dir(), 'node_modules/@pnpm.e2e/pkg-with-1-dep'))).toBeFalsy()
expect(fs.existsSync(path.resolve(virtualStoreDir, 'node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep'))).toBeFalsy()
// fetch records the marker that opts the next install out of hoist-pattern checks.
expect(JSON.parse(fs.readFileSync(modulesYamlPath, 'utf8')).virtualStoreOnly).toBe(true)
await install.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
frozenLockfile: true,
hoistPattern: ['*'],
linkWorkspacePackages: true,
storeDir,
preferOffline: true,
})
// If the modules dir had been purged, the directory's inode would change
// (rimraf + remake creates a new directory).
expect(fs.statSync(virtualStoreDir).ino).toBe(virtualStoreInodeBefore)
// The direct dep must be symlinked at the project root.
expect(fs.existsSync(path.resolve(project.dir(), 'node_modules/@pnpm.e2e/pkg-with-1-dep'))).toBeTruthy()
// The transitive dep must be hoisted under the virtual store
// (default hoistPattern is ['*']).
expect(fs.existsSync(path.resolve(virtualStoreDir, 'node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep'))).toBeTruthy()
// The marker must be cleared once the install completes the linking,
// otherwise a later install would keep skipping hoist-pattern checks.
expect(JSON.parse(fs.readFileSync(modulesYamlPath, 'utf8')).virtualStoreOnly).toBeUndefined()
})
test('fetch applies patches to dependencies when patchedDependencies key is bare package name', async () => {
const f = fixtures(import.meta.dirname)
const project = prepare({

View File

@@ -1601,6 +1601,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
packageManager: `${opts.packageManager.name}@${opts.packageManager.version}`,
pendingBuilds: ctx.pendingBuilds,
publicHoistPattern: ctx.publicHoistPattern,
virtualStoreOnly: opts.virtualStoreOnly,
prunedAt: opts.pruneVirtualStore || ctx.modulesFile == null
? new Date().toUTCString()
: ctx.modulesFile.prunedAt,

View File

@@ -62,7 +62,10 @@ export async function validateModules (
' Run "pnpm install" to recreate the modules directory.'
)
}
// virtualStoreOnly installs (e.g. `pnpm fetch`) force empty hoist patterns
// into .modules.yaml; the follow-up install must complete linking, not purge.
if (
!modules.virtualStoreOnly &&
!equals(modules.publicHoistPattern ?? [], opts.publicHoistPattern ?? [])
) {
if (opts.forceNewModules && (rootProject != null)) {
@@ -78,7 +81,7 @@ export async function validateModules (
const importersToPurge: ImporterToPurge[] = []
if (rootProject != null) {
if (!modules.virtualStoreOnly && rootProject != null) {
try {
if (!equals(opts.currentHoistPattern ?? [], opts.hoistPattern ?? [])) {
throw new PnpmError(

View File

@@ -676,6 +676,7 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
virtualStoreDir,
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
allowBuilds: opts.allowBuilds,
virtualStoreOnly: opts.virtualStoreOnly,
})
const currentLockfileDir = path.join(rootModulesDir, '.pnpm')
if (opts.useLockfile) {

View File

@@ -41,6 +41,10 @@ interface ModulesRaw {
injectedDeps?: Record<string, string[]>
hoistedLocations?: Record<string, string[]>
allowBuilds?: Record<string, boolean | string>
// True when the modules dir was populated by a virtualStoreOnly install
// (e.g. `pnpm fetch`) — the recorded hoist patterns are forced empty
// and must not be compared against user config on the next install.
virtualStoreOnly?: boolean
}
export type Modules = Omit<ModulesRaw, 'ignoredBuilds'> & {
@@ -123,6 +127,9 @@ export async function writeModulesManifest (
if (saveModules.publicHoistPattern == null) {
delete saveModules.publicHoistPattern
}
if (!saveModules.virtualStoreOnly) {
delete saveModules.virtualStoreOnly
}
if ((saveModules.hoistedAliases == null) || (saveModules.hoistPattern == null) && (saveModules.publicHoistPattern == null)) {
delete saveModules.hoistedAliases
}