Skip to content

Add @types/react as an optional peer dependency - #245

Merged
bvaughn merged 1 commit into
bvaughn:mainfrom
unrevised6419:add-types-react-peer-dependency
Aug 15, 2026
Merged

bvaughn merged 1 commit into
bvaughn:mainfrom
unrevised6419:add-types-react-peer-dependency

Conversation

@unrevised6419

@unrevised6419 unrevised6419 commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

react-error-boundary ships type declarations that depend on @types/react, but never declares that dependency. This PR adds it as an optional peer dependency — a manifest-only change, no code touched.

"peerDependencies": {
  "@types/react": "^18.0.0 || ^19.0.0",
  "react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
  "@types/react": { "optional": true }
}

The problem

dist/react-error-boundary.d.ts (v6.1.2) imports 12 type-only symbols from react:

import { Component } from 'react';
import { ComponentClass } from 'react';
import { ComponentType } from 'react';
import { Context } from 'react';
import { ErrorInfo } from 'react';
import { ForwardRefExoticComponent } from 'react';
import { FunctionComponentElement } from 'react';
import { PropsWithChildren } from 'react';
import { PropsWithoutRef } from 'react';
import { ProviderProps } from 'react';
import { ReactNode } from 'react';
import { RefAttributes } from 'react';

@types/react appears only in devDependencies (^19.1.8). The react package itself ships no types, so a consumer's TypeScript has to locate @types/react on its own — via ambient node_modules layout that this package neither declares nor controls.

How it breaks today

Reproduced in a pnpm 11.21.0 monorepo with enableGlobalVirtualStore: true. Under that layout packages live in a global store outside the consuming project (~/Library/pnpm/store/v11/links/@/react-error-boundary/6.1.2/<hash>/node_modules/react-error-boundary), so TypeScript walking up from the published .d.ts never reaches the consumer's node_modules.

tsc --traceResolution, starting from the published declaration file:

======== Module name 'react' was successfully resolved to
'.../links/@/react/18.3.1/<hash>/node_modules/react/index.js'
with Package ID 'react/index.js@18.3.1'. ========

It lands on index.js — the untyped runtime entry — instead of @types/react. Component then has no props, and every consumer gets:

error TS2607: JSX element class does not support attributes because it does not have a 'props' property.
error TS2786: 'ErrorBoundary' cannot be used as a JSX component.
  Its type 'typeof ErrorBoundary' is not a valid JSX element type.
    Types of construct signatures are incompatible.
      Type 'new (props: any) => ErrorBoundary' is not assignable to type 'new (props: any, deprecatedLegacyContext?: any) => Component<any, any, any>'.
        Type 'ErrorBoundary' is missing the following properties from type 'Component<any, any, any>': context, setState, forceUpdate, props, and 2 more.

After the fix

Verified by simulating the manifest above through a pnpm packageExtensions override, then reinstalling. The package's store directory gains @types as a sibling and the trace becomes:

======== Module name 'react' was successfully resolved to
'.../links/@types/react/18.3.31/<hash>/node_modules/@types/react/index.d.ts'
with Package ID '@types/react/index.d.ts@18.3.31'. ========

Both TS2607 and TS2786 disappear.

Why optional: true

JavaScript-only consumers have no @types/react. Under strictPeerDependencies — or npm's default peer auto-install — a required peer would fail their install for a dependency they have no use for. Marking it optional is the standard pattern for a types-only peer.

Why the range mirrors the react peer

The range only validates — the peer itself resolves from the consumer, so their @types/react version wins either way. Confirmed in the verification above: the test workspace pins 18.3.31 while this repo's devDependencies list ^19.1.8, and resolution correctly lands on 18.3.31.

So the range's job is to state what this package's declarations actually support, and ^18.0.0 || ^19.0.0 keeps that identical to the existing react peer. Pinning ^19 alone would wrongly force React 19 types onto React 18 consumers; a bare "*" would go the other way and never flag a genuinely incompatible types major. Mirroring is also what comparable packages do — @testing-library/react declares exactly "^18.0.0 || ^19.0.0" optional, zustand declares ">=18.0.0" optional, both matching their own react peer.

Happy to widen it to "*" if you'd rather not have to touch it again when React 20 lands — though the react peer range would need the same release at that point anyway.

Why this is worth fixing now

Classic node_modules layouts work today by accident, not by design. pnpm's default hoistPattern: ['*'] puts @types/react in node_modules/.pnpm/node_modules/, which happens to sit on the upward walk from every virtual-store package; npm and yarn hoist it to the project root, same accident. A global virtual store removes the accident and the undeclared dependency becomes a hard error.

pnpm has stated the global virtual store is intended to become the default. At that point every TypeScript consumer of this package hits this, not only those who opted in early.

Verification in this repo

  • pnpm run tsc — clean
  • pnpm run test:ci — 21/21 passing
  • prettier --check package.json — clean
  • pnpm install --frozen-lockfile --recursive — no lockfile change

Note this is a published-manifest change, so it needs a release to reach consumers.

Happy to open an issue for discussion first if you'd prefer that per CONTRIBUTING.md — I read the manifest-only scope as outside "changes to this API", but that's your call.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

@unrevised6419 is attempting to deploy a commit to the Brian Vaughn's projects Team on Vercel.

A member of the Team first needs to authorize it.

The published type declarations import 12 type-only symbols from "react",
but the react package ships no types of its own, so consumers must resolve
@types/react through ambient node_modules layout that this package neither
declares nor controls. Under pnpm's global virtual store the package lives
outside the consumer's project, the upward walk never reaches their
@types/react, and "react" resolves to the untyped index.js — producing
TS2607 and TS2786 on every use of ErrorBoundary.

Declaring the peer makes the dependency explicit. It is optional so
JavaScript-only consumers are unaffected, and the range mirrors the
existing react peer range so both stay consistent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@unrevised6419
unrevised6419 force-pushed the add-types-react-peer-dependency branch from f6a8748 to 9c2ab42 Compare August 12, 2026 13:59

@unrevised6419 unrevised6419 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@bvaughn bvaughn left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@bvaughn
bvaughn merged commit a326460 into bvaughn:main Aug 15, 2026
1 check failed
@unrevised6419
unrevised6419 deleted the add-types-react-peer-dependency branch August 15, 2026 13:15
@bvaughn

bvaughn commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Published in version 6.1.3

@unrevised6419

Copy link
Copy Markdown
Contributor Author

Thanks a lot!

HichamELBSI added a commit to strapi/design-system that referenced this pull request Sep 24, 2026
…react` (#2046)

### What does it do?

Declares `@types/react` as an **optional** peer dependency of the three
packages whose published type declarations import React types:
`@strapi/design-system`, `@strapi/icons` and `@strapi/ui-primitives`.

```json
"peerDependencies": {
  "@types/react": "^18.0.0",
  "react": "^18.0.0",
  ...
},
"peerDependenciesMeta": {
  "@types/react": { "optional": true }
}
```

Manifest-only. No source, build or runtime change.

### Why is it needed?

**This does not fail loudly at an import — it silently corrupts the
inferred shape of the library's public API.**

The published declarations import React types directly, e.g.
`dist/primitives/Box/Box.d.ts`:

```ts
import { CSSProperties } from 'styled-components';
import { ResponsiveProperty, ResponsiveThemeProperty } from '../../helpers/handleResponsiveValues';
import { PolymorphicComponentPropsWithRef } from '../../types';
import * as React from 'react';
```

86 declaration files in `@strapi/design-system`, 214 in `@strapi/icons`,
and 4 in `@strapi/ui-primitives` import from `react`. But `@types/react`
appears in neither `dependencies` nor `peerDependencies` of any of them.
The `react` package ships no types of its own, so a consumer's
TypeScript has to locate `@types/react` on its own — via ambient
`node_modules` layout that these packages neither declare nor control.
(The `styled-components` import on line 1 is fine; that package ships
its own types.)

When that ambient walk fails, resolution lands on the untyped runtime
entry instead:

```
======== Resolving module 'react' from '.../links/@strapi/design-system/2.2.3/<hash>/node_modules/@strapi/design-system/dist/primitives/Box/Box.d.ts'. ========
File '.../links/@strapi/design-system/2.2.3/<hash>/node_modules/react/index.js' exists - use it as a name resolution result.
```

`react/index.js` has no types, so `React.*` degrades to `any`. That
propagates through the polymorphic prop helpers — and `Pick` over `any`
**loses optionality**, because `Pick<any, 'tag'>` is `{ tag: any }`:
required, not optional.

So `dist/components/SubNav/SubNavHeader.d.ts`:

```ts
export interface SubNavHeaderProps extends Pick<TypographyProps<'h2'>, 'tag'>, Partial<Pick<SearchbarProps, 'onClear' | 'onChange' | 'onSubmit' | 'placeholder'>> {
    id?: string;
    label: string;
    ...
}
```

turns a genuinely optional `tag` into a required one, and correct
consumer code stops compiling:

```
admin/src/components/LeftMenu.tsx(32,5): error TS2741: Property 'tag' is missing in type '{ label: string; }' but required in type 'SubNavHeaderProps'.
```

`<SubNavHeader label={...} />` is valid usage per the design system's
own docs. The error names `tag`, which sends a consumer hunting through
`SubNav` for a bug that isn't there — the actual cause is three layers
away in an undeclared types dependency. **Every `Pick`/`Omit`-derived
prop type in the library is exposed to this same distortion**, so the
visible symptom differs per consumer, which makes it that much harder to
trace back here.

Declaring the peer dependency fixes it because it makes the package
manager place `@types/react` on the package's own resolution path,
rather than leaving it to whatever the ambient layout happens to be.

#### This is forward-looking, not one consumer's edge case

The failure was reproduced in a pnpm 11.21.0 monorepo with
`enableGlobalVirtualStore: true`. Under that layout packages live in a
global store outside the consuming project, so TypeScript walking up
from a published `.d.ts` never reaches the consumer's `node_modules`.
Both `2.2.3` and `2.2.4` reproduce.

pnpm has stated it intends to make the global virtual store the default.
At that point every TypeScript consumer of the design system hits this,
not only those who opted in early.

#### Two details a reviewer will ask about

- **`optional: true` is required, not cosmetic.** JavaScript-only
consumers have no `@types/react`. Under `strictPeerDependencies` (or
npm's peer auto-install) a non-optional peer would break their install.
- **The range mirrors each package's own `react` peer range, exactly.**
All three packages declare `react: "^18.0.0"`, so the types peer is
`^18.0.0` — no wider, no narrower. The types version must track the
React version the consumer actually runs; a wider range (e.g. `"*"`)
would let a consumer on React 18 satisfy the peer with `@types/react@19`
and get wrong types with no warning, and a narrower or different range
would fight the consumer's own valid choice. When these packages widen
their `react` peer to include React 19, the `@types/react` peer should
widen with it.

This is the established pattern in the ecosystem for React libraries
that ship types — both of these mirror their own `react` peer as an
optional peer:

- `@testing-library/react@16.3.1` → `"@types/react": "^18.0.0 ||
^19.0.0"`, optional
- `zustand@5.0.9` → `"@types/react": ">=18.0.0"`, optional

`@types/react-dom` is deliberately **not** added: no published `.d.ts`
in any of the three packages imports from `react-dom`, so there is
nothing to declare.

### How to test it?

The manifest change itself is verifiable by inspection, but to see the
underlying defect:

1. In a pnpm workspace, set `enableGlobalVirtualStore: true` in
`pnpm-workspace.yaml`.
2. Install `@strapi/design-system@2.2.4` and render `<SubNavHeader
label="…" />` in a `.tsx` file.
3. `tsc --noEmit` → `TS2741: Property 'tag' is missing`.
4. `tsc --traceResolution | grep "Resolving module 'react' from"` →
resolution lands on `react/index.js`, not `@types/react`.
5. Apply this manifest via a pnpm `packageExtensions` override,
reinstall, and repeat: `@types` appears as a sibling in the package's
store directory, resolution moves to the consumer's own `@types/react`,
and the errors clear.

Step 5 was confirmed end to end on a different package with the
identical defect (`react-error-boundary`) using exactly this manifest
shape.

Repo checks on this branch, on node 22.23.2 / yarn 3.6.4:

- `yarn build` — 4/4 tasks pass
- `yarn test:ts` — 6/6 tasks pass
- `yarn lint` — 6/6 tasks pass (1 pre-existing warning in
`docs/.storybook/main.ts`, untouched by this PR)
- `yarn test:unit` — 5/5 tasks pass; 267 tests in
`@strapi/design-system`, 46 in `@strapi/ui-primitives`, 0 failures

`oxfmt` does not process `.json` or `.md`, so the files changed here are
outside its scope; the repo's existing format drift is unrelated to this
branch.

A `patch` changeset is included for all three packages.

### Related issue(s)/PR(s)

Same defect and same fix shape, upstream in `react-error-boundary` —
useful as precedent:
bvaughn/react-error-boundary#245

---

**Note on base branch:** CONTRIBUTING.md asks for PRs against `develop`,
but that branch does not currently exist on this repository (`git
ls-remote --heads` shows only `main`), and the changesets config uses
`main` as `baseBranch` — so this targets `main`. Happy to retarget if
that's wrong.

Opened as a draft for maintainer review of the range choice before it
lands.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants