Skip to content
Thomas BerdyThomas Berdy
April 20, 2026 · 5 min read

Switching from npm to pnpm found 3 phantom dependencies in our React app

Switching from npm to pnpm found 3 phantom dependencies in our React app

A pnpm migration meant to speed up installs ended up exposing three phantom dependencies our React app had been shipping without declaring.

I expected the switch from npm to pnpm to be a weekend cleanup: install faster, cache smaller, move on. Instead the first pnpm install refused to complete. Three packages my app was importing had never been in package.json, and npm had been hoisting them into place for long enough that nobody remembered.

Why we moved at all

Two reasons, both boring. Install was getting long enough to notice, and our GitHub Actions cache was creeping up week after week, dragging restore times with it. I won’t claim specific before/after numbers because I didn’t measure them carefully. The improvement was obvious enough that nobody has asked me to justify it.

The rest of pnpm’s pitch is well-worn at this point. A content-addressable store and hard-linked node_modules, so the same package never duplicates across the dependency tree. I expected speed. The part worth writing about came later.

What pnpm caught

pnpm installs strictly. If a package imports something that isn’t in its dependencies or peerDependencies, pnpm doesn’t hoist a neighbor copy into view. Your code fails to resolve.

We had to add three entries to package.json:

"date-fns": "3.6.0",
"@tanstack/table-core": "8.21.3",
"react-is": "19.2.4"
  • date-fns is a peer dep of react-date-range. Our app code was importing date-fns directly, and npm’s hoisting had been silently resolving it via react-date-range’s own install.
  • @tanstack/table-core was a types-only phantom: we had a declare module block augmenting its types, but the package was never declared. TypeScript checked happily against something npm had hoisted from a transitive dep.
  • react-is came in through recharts. Same story: imported, never declared.

These are classic phantom dependencies. Our app was importing packages it didn’t declare. The imports worked because npm hoists everything into a flat top-level node_modules and lets anyone reach anything.

depcheck and npm ls would have caught it

Yes, fair pushback. Tools like depcheck, eslint-plugin-import/no-extraneous-dependencies, or npm ls can flag the same issues. We weren’t running any of them consistently.

The difference with pnpm is that the enforcement is free. It’s not an extra CI step you can forget to run. It’s pnpm install itself, and the audit happens whether you asked for it or not.

How npm hides what pnpm surfaces

npm (hoisted)                          pnpm (strict)
─────────────                          ─────────────
node_modules/                          node_modules/
├── react-date-range/                  ├── react-date-range -> .pnpm/rdr@x.y/...
├── date-fns/        ← hoisted,        └── (no date-fns here)
│                      app reaches         .pnpm/rdr@x.y/node_modules/
│                      it by                └── (peer dep must come from app)
│                      accident
└── (app imports
    date-fns)

npm builds a flat forest. Anything hoisted at the top level is resolvable by anyone, including code that never declared it. pnpm builds a strict graph. react-date-range only sees what it declared, and peer deps must come from the root app’s package.json.

npm’s behavior is a footgun. We hadn’t been bitten yet. We would have been, the day one of those parent packages dropped a peer dep or shipped a major.

How corepack pins pnpm in CI and Docker

pnpm ships a packageManager field you set in package.json:

"packageManager": "pnpm@10.33.0"

Corepack reads that field and provisions the exact version at call time. In our Dockerfile, corepack enable runs before any pnpm invocation. In CI and non-interactive contexts we also run corepack prepare pnpm@10.33.0 --activate to skip the download prompt newer Node versions show. That’s the whole setup. No npm i -g pnpm, no version drift between the CI image and the production build, no Dockerfile step that quietly pulls a newer pnpm the day someone rebuilds.

Upgrading pnpm is a one-line bump in package.json. Everyone gets the new version on the next install. For a tool whose job is reproducibility, pinning its own version through the same file it pins everything else is the right default.

Small details that added up

Things I didn’t anticipate:

  • GHA cache cleanup. The node_modules cache step disappeared from every workflow. pnpm’s store cache covers it, and layering both just wasted time restoring duplicate data.
  • pnpm dlx instead of npx. Every npx call in scripts and in playwright.config.ts became pnpm dlx. npx on a pnpm-managed repo reaches into npm’s global cache and produces confusing mismatches.
  • No more - separator. npm needs npm run script -- --flag to forward flags. pnpm passes args directly.
  • Playwright tsconfig. The phantom playwright package vanished once pnpm stopped hoisting it. We now reference @playwright/test directly.

You only notice them grepping through the repo for the fifth time.

Freezing the bots during the rebase

One operational detail worth sharing. A 36k-line diff (mostly lockfile churn) across 14 files is painful to keep rebased when Renovate and Dependabot are pushing updates every few hours. We used our own merge queue freeze feature, scoped to dependency bots only, while the PR was open. Humans could keep merging. Bots got paused.

I’ll admit it felt nice to reach for our own product to solve a migration problem. It also happened to be the fastest way I could think of to stop the noise.

Would I do it again

Yes, and sooner.

The migration itself was mechanical. The valuable part was the audit pnpm forced on us: three undeclared dependencies shipping in production for who knows how long. That’s the argument for pnpm I don’t see often enough. Install speed and disk footprint are nice. Having your package manager refuse to let you ship against a lie is better.

Stay ahead in CI/CD

Blog posts, release news, and automation tips straight in your inbox.

Recommended posts

We Stopped Our Config Builder From Destroying Your YAML, Then Found a Worse Bug Next to It
July 29, 2026·8 min read

We Stopped Our Config Builder From Destroying Your YAML, Then Found a Worse Bug Next to It

Our no-code config builder used to strip comments and anchors out of your .mergify.yml on every commit. Fixing it meant patching the document in place instead of re-dumping it, and an audit of the code next door turned up a parser bug that had been silently inverting a security condition in production.

Alexandre GaubertAlexandre Gaubert
The Native TypeScript Compiler Cut Our Typecheck From 13s to 3.5s. The Only Hard Part Was ESLint.
July 27, 2026·7 min read

The Native TypeScript Compiler Cut Our Typecheck From 13s to 3.5s. The Only Hard Part Was ESLint.

We swapped in TypeScript 7's native Go compiler and our dashboard typecheck went from 13s to 3.5s. The compiler was nearly a drop-in. The real work was keeping typescript-eslint alive when native 7.0 ships no importable compiler API.

Thomas BerdyThomas Berdy
Our Frontend Was Guessing What Our Parser Accepts. It Was Wrong for 70 of 75 Attributes.
July 17, 2026·8 min read

Our Frontend Was Guessing What Our Parser Accepts. It Was Wrong for 70 of 75 Attributes.

Most of the wrong menus were hiding valid operators, so nobody filed a bug. Instead of patching the heuristic, we made the engine's parser publish its own grammar through the config schema.

Thomas BerdyThomas Berdy