The Accidental Dependency
Yarn Classic hoists every package to the root node_modules. That means your
code can import anything any package depends on, whether you declared it or not.
You don't notice until you switch to a package manager that enforces what you
actually wrote in package.json.
We migrated this Nx monorepo from Yarn Classic to pnpm v10. The command itself
was uneventful: corepack enable pnpm && pnpm import converts yarn.lock
to pnpm-lock.yaml. What happened next was an audit we didn't plan for.
The First Failure
pnpm uses a symlinked node_modules structure. Each package can only access
its declared dependencies. On the first pnpm install, the root app failed to
resolve @danieljoffe.com/shared-ui. It had been importing the workspace
library for months without declaring it:
// apps/root/package.json — before
{
"dependencies": {
// shared-ui is missing entirely
}
}Yarn Classic resolved it through hoisting. pnpm refused. The fix was explicit:
// apps/root/package.json — after
{
"dependencies": {
"@danieljoffe.com/shared-ui": "workspace:*"
}
}One line. The kind of line that should have been there from the start.
Sentry and the Hoisting Escape Hatch
The second failure was subtler. Sentry's Node.js SDK intercepts module loading
at runtime using import-in-the-middle. That library patches require and
import calls to inject tracing hooks before your code runs. Under pnpm's
strict isolation, import-in-the-middle can't find the modules it needs to
patch because they live in nested .pnpm directories, not at the root.
pnpm provides an escape hatch: .npmrc hoist patterns.
# .npmrc
public-hoist-pattern[]=@sentry/*
public-hoist-pattern[]=*import-in-the-middle*These patterns tell pnpm to hoist matching packages to the root
node_modules, restoring the flat structure Sentry expects. It's a scoped
exception, not a global override. Every other package stays isolated.
Subpath Exports
The shared-ui library had a single barrel export. Consumers could
import { Button } from '@danieljoffe.com/shared-ui', but they couldn't
import individual files or style constants. Under Yarn Classic, deep imports
like @danieljoffe.com/shared-ui/styles/formStyles resolved through file
system traversal. pnpm requires explicit subpath exports in package.json:
{
"exports": {
".": { "default": "./dist/index.js" },
"./*": { "default": "./src/lib/*.tsx" },
"./styles/*": { "default": "./src/lib/styles/*.ts" },
"./types": { "default": "./src/lib/types.ts" }
}
}The ./* wildcard initially used *.tsx, which couldn't match .ts files in
the styles/ directory. We added explicit ./styles/* and ./types entries
to cover both extensions. Without pnpm's strictness, this mismatch would have
surfaced only after a consumer tried tree-shaking and got an empty module.
What We Gained
The migration touched 4 CI workflow files, 1 Dockerfile, and the root
package.json (converting Yarn resolutions to pnpm pnpm.overrides). In
return:
- Strict dependency resolution caught one phantom dependency and one incomplete export map
- Content-addressable storage means packages are stored once on disk, symlinked into each project
- CI install times dropped from Yarn Classic's flat copy to pnpm's linked approach
pnpm-workspace.yamlreplaced the implicitworkspacesfield inpackage.jsonwith an explicit workspace declaration
The Principle
Loose dependency resolution doesn't mean your dependency graph is correct. It
means your mistakes happen to work. pnpm's strict mode is a linter for your
package.json: it catches what you forgot to declare before production does.