npm ERESOLVE unable to resolve dependency tree after upgrading to React 19
Bumping React to 19 made npm install fail with ERESOLVE. The quick --legacy-peer-deps workaround installed, then crashed tooltips in staging. We learned to read the conflict, upgrade the real offender and use overrides only as a short, tested bridge.
React 19 went stable at the start of December, and our operations dashboard (a Vite single-page app
used by the support and logistics teams) was a good candidate for an early upgrade: few
dependencies, good test coverage. The pull request changed two lines in package.json and failed
in CI at the install step:
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: ops-dashboard@4.12.0
npm error Found: react@19.0.0
npm error node_modules/react
npm error react@"^19.0.0" from the root project
npm error
npm error Could not resolve dependency:
npm error peer react@"^17.0.0 || ^18.0.0" from @acme/ui-kit@2.7.3
npm error node_modules/@acme/ui-kit
npm error @acme/ui-kit@"^2.7.3" from the root project
npm error
npm error Fix the upstream dependency conflict, or retry
npm error this command with --force or --legacy-peer-deps
npm error to accept an incorrect (and potentially broken) dependency resolution.
@acme/ui-kit is our company's shared component library, published to a private registry. The
developer did what the last lines suggest, ran npm install --legacy-peer-deps, committed an
.npmrc with legacy-peer-deps=true so CI would pass, and merged. Staging broke the next morning.
Reproducing it
In staging, hovering any tooltip in the shipment table crashed the page section with:
TypeError: ReactDOM.findDOMNode is not a function
at Transition.performEnter (ui-kit.esm.js:4182:37)
The ERESOLVE message reproduces in a clean checkout every time. The crash reproduces only after forcing the install, which is the point: the warning was accurate.
What we ruled out
- A stale lockfile. We deleted
node_modulesandpackage-lock.jsonand ran a clean install; the same conflict appeared, because it comes from the declared ranges, not from the lock. - A transitive React copy.
npm ls reactshowed a singlereact@19.0.0, so the crash was not two Reacts on the page. - Our own code. No file in the app calls
findDOMNode; the stack trace points into the library's bundled transition component.
Root cause
@acme/ui-kit@2.7.3 declared react and react-dom as peer dependencies with the range
^17.0.0 || ^18.0.0, and it bundled an old transition helper that used ReactDOM.findDOMNode,
which React 19 removed. npm's resolver refused to install a tree where a peer dependency is
unsatisfied. --legacy-peer-deps told npm to ignore peer dependencies entirely, so the install
"succeeded" with a library that was genuinely incompatible with the new React.
What is happening under the hood
A peer dependency is a library saying "I need the host app to provide this package, in this range".
UI libraries declare React as a peer so the app has exactly one React instance. Since npm 7, npm install installs peer dependencies automatically and fails with ERESOLVE when it cannot find one
version that satisfies everybody.
The message has a fixed structure that answers three questions:
| Part of the message | What it tells you |
|---|---|
While resolving: ops-dashboard@4.12.0 |
Which package's install was being computed (here the root project) |
Found: react@19.0.0 ... from the root project |
What is already chosen and who asked for it |
Could not resolve dependency: peer react@"^17.0.0 || ^18.0.0" from @acme/ui-kit@2.7.3 |
The package whose requirement cannot be met, and its range |
The two escape hatches behave differently:
| Flag | What npm does | Risk |
|---|---|---|
--legacy-peer-deps |
Ignores peer dependencies, as npm 6 did. Does not install or check them | Incompatible peers install silently; missing peers are not installed at all |
--force |
Resolves the conflict anyway, picking versions that break a declared range | Same incompatibility, plus it overrides other safety checks |
Both make the error go away without changing whether the code works together.
Environment
| Component | Version / value |
|---|---|
| Node.js | 22.12.0 |
| npm | 10.9.0 |
| App | Vite 6, React 19.0.0, react-dom 19.0.0, TypeScript 5.7 |
| Library | @acme/ui-kit 2.7.3, peer react@^17.0.0 || ^18.0.0 |
| Workaround in place | .npmrc with legacy-peer-deps=true |
Minimal reproduction
{
"name": "peer-demo",
"private": true,
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@acme/ui-kit": "^2.7.3"
}
}
npm install
# npm error code ERESOLVE ...
Any public library whose peer range stops at React 18 produces the same output.
Who is affected
Every project upgrading a framework with a large ecosystem of peer-dependent libraries: React, Angular, ESLint plugins, TypeScript-related tooling, Storybook addons. Yarn and pnpm report the same conflict differently (pnpm prints peer warnings, and can be configured to fail on them), but the incompatibility is the same.
The fix
1. Remove the workaround so the resolver can protect us again:
git rm .npmrc
2. Find every package that blocks the upgrade, not just the first one npm reports. npm explain
prints why a package is in the tree and who requires which range:
npm explain react
npm ls react react-dom
We also listed every installed package's React peer range in one pass:
npm query ':attr(peerDependencies, [react])' | jq -r '.[] | "\(.name)@\(.version) \(.peerDependencies.react)"'
That surfaced two more libraries with React 18 caps: a virtualised list and an older drag-and-drop helper.
3. Upgrade the offenders. The design system team had a React 19 compatible 3.0.0 in beta,
with the transition helper rewritten to use refs. We upgraded to it and adjusted the two breaking
prop renames listed in its changelog. The virtualised list already had a release with ^19 in its
peer range. The drag-and-drop helper did not.
"dependencies": {
- "@acme/ui-kit": "^2.7.3",
+ "@acme/ui-kit": "^3.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
4. Bridge the last one with a scoped override, only after reading its source and testing it. The
helper used no removed APIs; its peer range simply had not been updated. overrides lets the root
project decide which React that one package receives, using $ to reference our own direct
dependency spec:
{
"overrides": {
"legacy-dnd-helper": {
"react": "$react",
"react-dom": "$react-dom"
}
}
}
The override is scoped to one package, documented in the pull request with a link to the upstream issue, and has a reminder to remove it when upstream releases.
Alternatives and trade-offs
- Stay on React 18 until the ecosystem catches up. Completely reasonable for a production app a few weeks after a major release; there is no prize for upgrading first.
--legacy-peer-depsfor a spike branch to see what breaks at runtime. Useful as an experiment, never as the committed state.- Replace the library. For the drag-and-drop helper, a maintained alternative was on our list, but the swap touched more code than the override.
How to test the fix
rm -rf node_modules
npm install # must succeed without flags
npm ls react react-dom # one copy each, no "invalid" markers
npm run typecheck && npm test
npx playwright test e2e/tooltips.spec.ts e2e/drag-reorder.spec.ts
npm ls prints invalid or ELSPROBLEMS when an installed version breaks a declared range, so a
clean output is meaningful.
Confirming in production
We shipped the upgrade on 23 December behind the usual canary. Over the next week:
- Zero
findDOMNodeerrors in the browser error tracker (there had been 214 in staging during the broken day). - Frontend error rate per session stayed at the pre-upgrade baseline of about 0.3 percent.
- The production bundle shrank by 9 KB gzipped, mostly from the old transition helper leaving the design system.
Regression guard
- CI runs
npm ciwith no flags, and a check fails the build if.npmrccontainslegacy-peer-depsorforce. - A scheduled job runs
npm outdatedand opens a ticket when a peer-dependent library publishes a new major, so we see caps before the next framework upgrade. - The overrides block has a code owner on the platform team; adding an entry needs their review and a linked upstream issue.
Security implications
Peer conflicts are mainly a correctness issue, but the workarounds matter for security. A
project-wide legacy-peer-deps=true hides every future conflict, including the moment a security
release of a library raises its minimum React version. Overrides can also pin vulnerable versions
for a long time if nobody removes them. Keep overrides scoped, reviewed and tracked, and keep
npm audit in CI so pinned versions still get flagged.
Prevention checklist
- Read the
Could not resolve dependencylines; they name the exact package and range. - Never commit
legacy-peer-deps=trueor rely on--forcein CI. - Audit peer ranges with
npm queryornpm explainbefore a framework major upgrade. - Upgrade or replace blocking libraries; use scoped
overridesonly after testing. - Treat a passing install as necessary, not sufficient: run the UI tests that cover the library.
References
- npm documentation,
package.jsonpeerDependencies and overrides — https://docs.npmjs.com/cli/v10/configuring-npm/package-json - npm documentation,
npm install(--legacy-peer-deps,--force) — https://docs.npmjs.com/cli/v10/commands/npm-install - npm documentation,
npm explain— https://docs.npmjs.com/cli/v10/commands/npm-explain - npm documentation,
npm queryand dependency selectors — https://docs.npmjs.com/cli/v10/commands/npm-query - React blog, React 19 Upgrade Guide (removed APIs including
findDOMNode) — https://react.dev/blog/2024/04/25/react-19-upgrade-guide
Add a label
Embed this post
<iframe src="https://hibug.me/npm-eresolve-unable-to-resolve-dependency-tree" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Related Stories
CI passed with stale node_modules: a cache key without the lockfile hash
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.