[BUG] Isolated mode does not materialize optional peer dependencies into the dependent's store `node_modules`
## Is there an existing issue for this?
- [x] I have searched the existing issues
## This issue exists in the latest npm version
- [x] 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.
```bash
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
```bash
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:
```js
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:
```ini
install-strategy = "linked"
```
关闭于 2026-06-03 0 条评论