mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-09 08:54:57 -04:00
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:
8
.changeset/fetch-install-no-recreate.md
Normal file
8
.changeset/fetch-install-no-recreate.md
Normal 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).
|
||||
@@ -284,6 +284,8 @@ export type InstallCommandOptions = Pick<Config,
|
||||
| 'frozenLockfile'
|
||||
| 'global'
|
||||
| 'globalPnpmfile'
|
||||
| 'hoistPattern'
|
||||
| 'publicHoistPattern'
|
||||
| 'ignorePnpmfile'
|
||||
| 'ignoreScripts'
|
||||
| 'injectWorkspacePackages'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user