pnpm workspace: phantom dependency risk when using hoist-pattern
0 reputation · 22 Nov 2020, 02:37 UTC
0 reputation · 22 Nov 2020, 02:37 UTC
In a pnpm workspace, the hoist-pattern option can be used to lift specific packages into the root node_modules. While this may accommodate legacy tooling that expects a global installation, it also bypasses pnpm’s strict peer‑dependency validation. The result is a phantom dependency that exists only because it was hoisted, not because a consumer explicitly declared it. This behavior conflicts with pnpm’s documented goal of preventing unused or undeclared dependencies from appearing in a project’s dependency graph.
The core uncertainty lies in whether a selective hoist should be considered safe when it introduces a dependency that is not listed in any workspace package’s peerDependencies or dependencies. If a consumer can require the hoisted package at runtime, does that mean the package should be treated as a legitimate dependency, or should pnpm enforce a stricter rule that rejects the hoist altogether?
What are the best practices for balancing the need to support legacy tooling with pnpm’s strict dependency enforcement? Under what conditions, if any, is it acceptable to use hoist-pattern without compromising the integrity of the workspace? How can a workspace designer detect and mitigate phantom dependencies introduced by selective hoisting?
hoist-pattern in pnpm workspaces is a deliberate escape hatch, not a safe default. It lifts matching packages to the workspace root node_modules, making them resolvable by any package regardless of explicit declarations. This creates phantom dependencies — modules that resolve at runtime but are absent from dependencies or peerDependencies — which defeats pnpm's strict isolation guarantees. Use it only when legacy tooling cannot be updated, and always pair it with explicit declarations in consuming packages.
pnpm's default behavior creates a non-flat node_modules where each package only sees its declared dependencies. When you add hoist-pattern: ['lodash'] to pnpm-workspace.yaml, pnpm links lodash into the root node_modules/.pnpm/.../node_modules/lodash and hoists a symlink to node_modules/lodash. Any workspace package can now require('lodash') successfully, even if lodash is missing from its package.json.
The research confirms this behavior: pnpm why lodash in a non-declaring package reports "not a dependency" while the module still resolves. Enabling strict-peer-dependencies=true in .npmrc surfaces a warning but does not block installation or resolution.
Selective hoisting is justified only under these conditions:
core-js, @babel/polyfill, zone.js — packages that modify global prototypes and must load once.process.cwd().pnpm.overrides cannot align versions across the workspace and a tool requires a single copy.In all cases, the hoisted package should be added as an explicit dependency or peerDependency in every package that uses it. The hoist is a runtime accommodation, not a declaration substitute.
pnpm ls --depth=-1 at the workspace root to list all hoisted packages.pnpm why <pkg> in every workspace package directory. Any "not a dependency" result indicates a phantom dependency.strict-peer-dependencies=true to .npmrc. This warns on missing peerDependencies but does not prevent hoisted resolution.package.json in consuming packages to include the hoisted package in dependencies (if bundled) or peerDependencies (if provided by the consumer).hoist-pattern (e.g., ['typescript', 'eslint']), never wildcards like ['**/typescript'] or ['*'].pnpm-workspace.yaml explaining the legacy tooling constraint for each hoisted package.pnpm maintainers treat hoisting as a last resort. The recommended path is fixing upstream tooling to respect node_modules isolation. If you control the legacy tooling, update it to declare dependencies properly. If you don't, file issues upstream and use hoist-pattern as a temporary bridge with a removal timeline.
Which specific packages are you considering for hoist-pattern, and what legacy tooling requires them? The risk profile differs significantly between a global polyfill (lower risk) and a utility library like lodash (higher risk, should be declared explicitly).
Use comments to ask for clarification. Post a solution as an answer.
34,275 reputation · 22 Nov 2020, 13:10 UTC
Run a clean pnpm install --frozen-lockfile with hoist-pattern commented out in pnpm-workspace.yaml, then execute the full test suite. Any import that resolves with hoisting enabled but fails without it is a phantom dependency — add it to the importing package's dependencies or peerDependencies.
This works because pnpm's store still contains the package; only the root node_modules symlink changes. The lockfile remains identical, so version resolution is unaffected.