Skip to content

[BUG] Isolated mode does not materialize optional peer dependencies into the dependent's store node_modules #9460

@manzoorwanijk

Description

@manzoorwanijk

Is there an existing issue for this?

  • I have searched the existing issues

This issue exists in the latest npm version

  • I am using the latest npm

Current Behavior

Under install-strategy=linked, when a package in the store declares an optional peer dependency (peerDependenciesMeta: { x: { optional: true } }), the installer does not link that peer into the package's isolated node_modules unless the peer happens to be provided as a plain dependency by the package's direct parent.
If the peer is provided by an ancestor further up the graph, or by a devDependency, the optional peer is silently omitted from the package's store node_modules.

Required peers do not have this problem: they are materialized in all of those cases.

This is an asymmetry between required and optional peers that has nothing to do with whether the peer is actually available in the graph — it is available, and an ancestor provides it, but only required peers get linked.

Why it matters

Many widely-used React libraries declare @types/react as an optional peer (@emotion/react, @radix-ui/react-*, @base-ui/react, react-remove-scroll, react-style-singleton, use-callback-ref, use-sidecar, html-react-parser, @testing-library/react, etc.).
Their shipped .d.ts reference React types via import('react').
Because the linked installer does not link @types/react into their store context, those .d.ts cannot resolve the React typings under strict isolation, the exported types degrade (React generics collapse to any / become unresolved), and the breakage cascades into first-party packages that consume them (dozens of TypeScript errors in consumers such as @wordpress/components).

The failure stays hidden as long as the optional peer is hoisted at the top-level node_modules (e.g. declared as a root dependency), because store packages then find it via Node's walk-up to the project root.
It surfaces the moment the peer is moved out of the root and into the workspaces that use it (a legitimate thing to want).

pnpm — the design reference for npm's linked strategy — materializes both required and optional peers when an ancestor provides them, resolving the optional peer to the consumer-provided instance so it stays a singleton.

Expected Behavior

When an optional peer dependency of a store package is provided anywhere in the dependent graph, the linked strategy should link that peer into the package's store node_modules, the same way it already does for required peers and the same way pnpm does.
If nobody provides the optional peer, it should be omitted silently (no warning) — preserving "optional" semantics.
The peer should resolve to the consumer-provided instance so singletons (notably @types/react) are preserved.

Steps To Reproduce

@emotion/react declares react as a required peer and @types/react as an optional peer.
A workspace provides both — react as a regular dependency and @types/react as a devDependency — but does not declare either at the project root, so neither is hoisted to the top-level node_modules to mask the problem.

rm -rf /tmp/linked-optpeer-repro
mkdir -p /tmp/linked-optpeer-repro/packages/app
cd /tmp/linked-optpeer-repro

echo 'install-strategy=linked' > .npmrc

cat > package.json << 'EOF'
{
  "name": "linked-optpeer-repro",
  "version": "1.0.0",
  "private": true,
  "workspaces": ["packages/app"]
}
EOF

cat > packages/app/package.json << 'EOF'
{
  "name": "app",
  "version": "1.0.0",
  "dependencies": {
    "@emotion/react": "11.14.0",
    "react": "19.2.0"
  },
  "devDependencies": {
    "@types/react": "19.2.2"
  }
}
EOF

npm install

# @types/react IS in the graph (linked into the workspace that declares it):
ls packages/app/node_modules/@types          # → react
ls -d node_modules/.store/@types/react@*      # → present in the store

# but @emotion/react's isolated node_modules only gets the REQUIRED peer:
ls node_modules/.store/@emotion/react@*/node_modules
# → @babel  @emotion  hoist-non-react-statics  react
#   `react` (required peer) is present, `@types` (optional peer) is MISSING

Result: @emotion/react's store node_modules contains react (required peer) but not @types/react (optional peer), even though @types/react is present in the graph and provided by the package that depends on @emotion/react.

Cleanup

rm -rf /tmp/linked-optpeer-repro

Minimal reproduction in the arborist test harness

The same asymmetry reproduces with synthetic packages via workspaces/arborist/test/fixtures/isolated-nock.js.
The cases below are identical except for the peerDependenciesMeta.optional flag and where the peer is provided.

Provider of the peer Required peer Optional peer
Direct parent, plain dependency materialized materialized
Direct parent, devDependency materialized missing
Ancestor / grandparent (transitive) materialized missing

For example, this graph (optional peer provided as a devDependency of the direct parent) leaves which out of bar's store node_modules, while the required-peer equivalent materializes it:

const graph = {
  registry: [
    { name: 'which', version: '1.0.0' },
    { name: 'bar', version: '1.0.0', peerDependencies: { which: '*' }, peerDependenciesMeta: { which: { optional: true } } },
    { name: 'consumerA', version: '1.0.0', dependencies: { bar: '*' }, devDependencies: { which: '1.0.0' } },
  ],
  root: { name: 'foo', version: '1.2.3', dependencies: { consumerA: '*' } },
}
// after reify({ installStrategy: 'linked' }):
// node_modules/.store/bar@1.0.0-<hash>/node_modules/ has NO `which` symlink

Environment

  • npm: 12.0.0-pre.0
  • Node.js: v24.15.0
  • OS Name: macOS 26.5
  • System Model Name: MacBook Pro
  • npm config:
install-strategy = "linked"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions