Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · December 30, 2024 · 8 min read · 6 Views
Banner: a dependency tree where one branch refuses to connect to a newer React version

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_modules and package-lock.json and 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 react showed a single react@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

  1. 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.
  2. --legacy-peer-deps for a spike branch to see what breaks at runtime. Useful as an experiment, never as the committed state.
  3. 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 findDOMNode errors 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 ci with no flags, and a check fails the build if .npmrc contains legacy-peer-deps or force.
  • A scheduled job runs npm outdated and 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 dependency lines; they name the exact package and range.
  • Never commit legacy-peer-deps=true or rely on --force in CI.
  • Audit peer ranges with npm query or npm explain before a framework major upgrade.
  • Upgrade or replace blocking libraries; use scoped overrides only after testing.
  • Treat a passing install as necessary, not sufficient: run the UI tests that cover the library.

References

Add a label

#react #nodejs #npm #peer-dependencies #eresolve
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.