Add @types/react as an optional peer dependency - #245
Merged
bvaughn merged 1 commit intoAug 15, 2026
Merged
Conversation
|
@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
force-pushed
the
add-types-react-peer-dependency
branch
from
August 12, 2026 13:59
f6a8748 to
9c2ab42
Compare
unrevised6419
marked this pull request as ready for review
August 12, 2026 14:01
Owner
|
Published in version 6.1.3 |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
react-error-boundaryships 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.The problem
dist/react-error-boundary.d.ts(v6.1.2) imports 12 type-only symbols fromreact:@types/reactappears only indevDependencies(^19.1.8). Thereactpackage itself ships no types, so a consumer's TypeScript has to locate@types/reacton its own — via ambientnode_moduleslayout 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.tsnever reaches the consumer'snode_modules.tsc --traceResolution, starting from the published declaration file:It lands on
index.js— the untyped runtime entry — instead of@types/react.Componentthen has noprops, and every consumer gets:After the fix
Verified by simulating the manifest above through a pnpm
packageExtensionsoverride, then reinstalling. The package's store directory gains@typesas a sibling and the trace becomes:Both TS2607 and TS2786 disappear.
Why
optional: trueJavaScript-only consumers have no
@types/react. UnderstrictPeerDependencies— 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
reactpeerThe range only validates — the peer itself resolves from the consumer, so their
@types/reactversion wins either way. Confirmed in the verification above: the test workspace pins18.3.31while this repo'sdevDependencieslist^19.1.8, and resolution correctly lands on18.3.31.So the range's job is to state what this package's declarations actually support, and
^18.0.0 || ^19.0.0keeps that identical to the existingreactpeer. Pinning^19alone 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/reactdeclares exactly"^18.0.0 || ^19.0.0"optional,zustanddeclares">=18.0.0"optional, both matching their ownreactpeer.Happy to widen it to
"*"if you'd rather not have to touch it again when React 20 lands — though thereactpeer range would need the same release at that point anyway.Why this is worth fixing now
Classic
node_moduleslayouts work today by accident, not by design. pnpm's defaulthoistPattern: ['*']puts@types/reactinnode_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— cleanpnpm run test:ci— 21/21 passingprettier --check package.json— cleanpnpm install --frozen-lockfile --recursive— no lockfile changeNote 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