Skip to content

Switch to npm install-strategy=linked #76195

@manzoorwanijk

Description

@manzoorwanijk

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=linked in .npmrc
  • Pin npm v10.9.6 via packageManager in devEngines field (contains required linked strategy bug fixes)
  • CI workflows updated to install the correct npm version from packageManager
  • CI caching simplified — node_modules caching removed since npm ci deletes it before installing; relies on actions/setup-node's cache: npm for ~/.npm download cache instead
  • patch-package patch filenames updated to use ++ separator for nested/linked paths
  • TypeScript typeRoots updated to include ./node_modules/@types (no longer auto-resolved via hoisting)
  • Storybook dependencies restructured so addons are resolvable from the storybook CLI package
  • @emotion/react added as root devDependency for Vite to resolve @emotion/react/jsx-runtime
  • Jest config adjusted to avoid OOM from scanning the large .store directory
  • Snapshot updates for stylis version 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 in node_modules because 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 when legacy-peer-deps was enabled, breaking require() 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 esbuild whose 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 legacyPeerDeps enabled, 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) — Relative file: 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 install a 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 missing version/resolved fields and a flat-vs-nested tree structure mismatch. Also added cleanup of orphaned .store entries 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=dev silently ignored (#9066, #9081) — npm install --omit=dev with linked strategy installed all dependencies including devDependencies. The isolated reifier's makeIdealGraph unconditionally 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's devDependencies were always hoisted because #rootDeclaredDeps unconditionally 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 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. Not needed after core/changeset#61873. 🎉

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.

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions