Is there an existing issue for this?
This issue exists in the latest npm version
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"
Is there an existing issue for this?
This issue exists in the latest npm version
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 isolatednode_modulesunless the peer happens to be provided as a plaindependencyby 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 storenode_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/reactas 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.tsreference React types viaimport('react').Because the linked installer does not link
@types/reactinto their store context, those.d.tscannot resolve the React typings under strict isolation, the exported types degrade (React generics collapse toany/ 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/reactdeclaresreactas a required peer and@types/reactas an optional peer.A workspace provides both —
reactas a regular dependency and@types/reactas a devDependency — but does not declare either at the project root, so neither is hoisted to the top-levelnode_modulesto mask the problem.Result:
@emotion/react's storenode_modulescontainsreact(required peer) but not@types/react(optional peer), even though@types/reactis present in the graph and provided by the package that depends on@emotion/react.Cleanup
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.optionalflag and where the peer is provided.dependencydevDependencyFor example, this graph (optional peer provided as a
devDependencyof the direct parent) leaveswhichout ofbar's storenode_modules, while the required-peer equivalent materializes it:Environment