-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Description
What problem does this address?
Gutenberg publishes 80+ packages to npm. These packages work in the monorepo but frequently break for external consumers because npm's default hoisted strategy lets packages import anything that happens to be installed at the root — even undeclared dependencies. Tests pass, CI is green, the PR gets merged, and then someone installs @wordpress/block-editor in their project and gets Cannot find module.
This isn't hypothetical. A research report found 60+ issues spanning 6 years — all tracing back to this root cause: missing dependencies on react, @babel/runtime, other @wordpress/* packages, ESLint plugins, and third-party packages.
Background
The preferred solution is to switch to pnpm, which enforces strict dependency isolation and also provides supply chain security features (build script controls, release age delays, trust policies) that npm lacks entirely. A ready-to-merge PR and a community discussion exist for this. However, the community has requested a Make Core proposal, which is still under review.
In the meantime, npm's install-strategy=linked offers a temporary workaround that catches phantom dependencies without switching package managers. It installs all packages into a flat .store directory inside node_modules, then creates symlinks and hardlinks to the correct locations. Each package can only resolve its own declared dependencies — similar to pnpm's isolation model. Note that workspace packages can still access dependencies declared in the root package.json, because Node's module resolution algorithm traverses up the directory tree. This is not specific to the linked strategy — it's how Node works. A parallel effort to clean up root dependencies will ensure packages are fully sandboxed.
Important limitations compared to pnpm:
- No supply chain security features (build script controls, release age delays, trust policies)
- The linked strategy is experimental and required significant upstream bug fixes (see below)
- No content-addressable storage — each project gets its own full copy of dependencies
- No built-in lockfile conflict resolution
A nested install strategy was also attempted, but it is also buggy and exhausts CI runner resources due to the deeply nested node_modules structure.
What is your proposed solution?
Adopt install-strategy=linked in .npmrc as a stopgap until the pnpm proposal is approved. A proof-of-concept PR demonstrates this working end-to-end:
What the POC PR covers
- Set
install-strategy=linkedin.npmrc - Pin npm v10.9.6 via
packageManagerindevEnginesfield (contains required linked strategy bug fixes) - CI workflows updated to install the correct npm version from
packageManager - CI caching simplified —
node_modulescaching removed sincenpm cideletes it before installing; relies onactions/setup-node'scache: npmfor~/.npmdownload cache instead patch-packagepatch filenames updated to use++separator for nested/linked paths- TypeScript
typeRootsupdated to include./node_modules/@types(no longer auto-resolved via hoisting) - Storybook dependencies restructured so addons are resolvable from the
storybookCLI package @emotion/reactadded as root devDependency for Vite to resolve@emotion/react/jsx-runtime- Jest config adjusted to avoid OOM from scanning the large
.storedirectory - Snapshot updates for
stylisversion alignment
Upstream npm fixes (contributed as part of this effort)
The linked install strategy was largely untested with real-world monorepos. Testing it against Gutenberg (~200 workspace packages) uncovered several bugs in npm's isolated-reifier.js and related code. These were fixed upstream across 10+ PRs and backported to npm v10 via npm/cli#9011 and npm/cli#9084:
-
Scoped packages, aliases, and peer deps (#8996) — Scoped workspace packages (e.g.
@wordpress/components) lost their@scope/prefix innode_modulesbecause symlink names came from the folder basename instead of the package name. Aliased packages (e.g."prettier": "npm:wp-prettier@3.0.3") were symlinked under the real package name instead of the alias. Peer dependencies weren't placed in the store whenlegacy-peer-depswas enabled, breakingrequire()calls for peer deps. -
Duplicate postinstall script execution (#9013) — Postinstall scripts ran twice for every store package — once for the store entry and once for its symlink. For packages like
esbuildwhose postinstall modifies files in-place, this race condition corrupted the install. -
Workspace-filtered installs (#9041) —
npm install --workspace=<name>crashed with linked strategy because the isolated tree used plain Arrays and objects incompatible with the workspace-filtered install code path, which expects Maps and Sets. -
EEXIST on workspace peer deps (#9051) — When a workspace had a peer dependency on another workspace with
legacyPeerDepsenabled, Link nodes weren't unwrapped, creating duplicate workspace proxies that raced during symlink creation and caused EEXIST errors on subsequent installs. -
Windows EPERM during bin-linking (#9028) — On Windows, antivirus (Windows Defender) and the search indexer can transiently lock recently-written files, causing
fs.rename()to fail with EPERM during the bin-linking phase. The linked strategy amplifies this because it writes all packages into.store/in parallel. Fixed by adding retry with exponential backoff for EPERM/EACCES/EBUSY on Windows. -
Relative
file:dependencies (#9030) — Relativefile:dependencies (e.g.file:./project2) were incorrectly classified as external and routed through the store extraction path instead of being symlinked directly, causing ENOENT errors because the relative path was resolved from the wrong directory. -
Full reinstall on every run (#9031) — Running
npm installa second time (with nothing changed) performed a full reinstall instead of recognizing everything is up-to-date. The proxy tree didn't match the actual tree because of missingversion/resolvedfields and a flat-vs-nested tree structure mismatch. Also added cleanup of orphaned.storeentries left behind by updated or removed dependencies. -
Undeclared workspaces hoisted to root (#9076) — All workspace packages were unconditionally symlinked into root
node_modules/, regardless of whether the root or any other workspace declared them as dependencies. This defeated the strict isolation that the linked strategy is supposed to provide — any workspace could import any other workspace without declaring it. Fixed by filtering undeclared workspace deps and only creating root-level workspace symlinks for declared dependencies. -
--omit=devsilently ignored (#9066, #9081) —npm install --omit=devwith linked strategy installed all dependencies including devDependencies. The isolated reifier'smakeIdealGraphunconditionally traversed all edges without consulting the omit option. A follow-up fix addressed two additional cases: shared external packages (a root devDependency that is also a workspace prodDependency incorrectly got a root symlink) and workspace dev deps (workspaces listed in root'sdevDependencieswere always hoisted because#rootDeclaredDepsunconditionally included devDependency keys).
Remaining items to sort out before merging
1. Upgrade lint-staged from v10 to v15+
lint-staged@10.0.2 depends on listr -> @samverschueren/stream-to-observable -> any-observable. any-observable tries to auto-detect an Observable implementation by calling require('rxjs') from its own package directory. With linked strategy, each package in .store is isolated and can only resolve its own declared dependencies. Since rxjs is a dependency of listr (the parent), not of any-observable, the require('rxjs') call fails and the pre-commit hook crashes:
Error: Cannot find any-observable implementation nor global.Observable.
Modern lint-staged (v13+) dropped the listr dependency entirely, so upgrading resolves this.
2. Rewrite license check scripts (#75039)
The CI license check uses npm query to find production dependencies, but this doesn't work correctly with the linked strategy. The fix (ready PR) rewrites the license check to use Node's native module resolution (findPackageJSON in Node 22.14+, with createRequire fallback), making it package-manager agnostic. This also fixes a pre-existing issue where npm query incorrectly captured transitive dev dependencies (e.g. Jest internals via @wordpress/scripts), requiring manual maintenance of an ignored list.
3. Clean up root dependencies (#75041)
The linked strategy isolates dependencies within the .store, but workspace packages can still access any dependency declared in the root package.json because Node's module resolution traverses up the directory tree. This means a workspace package could import a root-level dependency without declaring it, and it would work locally but break when published. Moving non-essential dependencies out of the root package.json ensures packages are fully sandboxed.
Coordination: npm version and wordpress-develop
This change requires npm v10.9.6+. wordpress-develop now clones Gutenberg and runs Not needed after core/changeset#61873. 🎉npm ci to build it from source. wordpress-develop's current engines constraint is npm >= 10.2.3, and its build script (checkout-gutenberg.js) runs npm ci inside the cloned Gutenberg directory using whatever npm version is available — so wordpress-develop would need to update its engines.npm constraint and install the correct npm version in its build script before running npm ci on Gutenberg. This also applies to wordpress-develop dev servers (e.g. the distributed hosting test runners) which build Gutenberg from source — they would need the required npm version installed.
The planned Node.js 24 update will help here — Node 24 ships with npm 11, but we specifically need npm v11.11.1 which contains the linked strategy fixes. That npm version has not yet landed in a Node 24 release but is expected in the coming days. Once the appropriate Node 24 minor/patch includes npm v11.11.1, we can update to that version.
Next steps
The remaining items (lint-staged upgrade, license check rewrite, root dependency cleanup) are improvements that benefit the project regardless of whether the linked strategy is adopted. They can be worked on independently and in parallel while we wait for the required Node/npm version to become available. Once those items are resolved and the Node 24 update includes npm v11.11.1, the switch to install-strategy=linked can be merged with minimal additional work.