Debugging a Published Package with npm link
Reproduce a consumer's bug locally: why npm link diverges from a real install, when to use pack-and-install or yalc instead, and how to avoid duplicate-instance artefacts.
A consumer reports a failure that reproduces in their application and not in your test suite. The instinct is npm link, and it usually works — but it introduces its own artefacts, some of which look exactly like the bug you are chasing:
Warning: Invalid hook call. You might have more than one copy of React in the same app.
That warning appeared because of the link, not because of the bug. This guide covers what a link actually changes, when its divergence matters, and the two alternatives that reproduce a consumer’s environment more faithfully.
Root Cause
npm link creates a symlink in the consuming project’s node_modules pointing at your library’s working directory. Three consequences follow, and each can either help or mislead.
The link exposes the whole directory, not the packed subset, so files excluded by files are reachable and the exports map is evaluated against a tree containing source, tests and configuration. The link brings its own node_modules, so any package installed in your library — including dev-dependency copies of frameworks — resolves from there rather than from the application. And the link is live, so a rebuild is visible immediately, which is the reason to use it in the first place.
Minimal Reproduction
cd ~/work/my-library && npm link
cd ~/work/consumer-app && npm link @scope/my-library
node -e "console.log(require.resolve('@scope/my-library'))"
node -e "console.log(require.resolve('react'))"
node -e "console.log(require.resolve('react', { paths: [require.resolve('@scope/my-library')] }))"
/home/dev/work/my-library/dist/index.cjs
/home/dev/work/consumer-app/node_modules/react/index.js
/home/dev/work/my-library/node_modules/react/index.js ← a second copy
The third line is the artefact. Nothing about the consumer’s real install produces it.
What a Link Changes
Step-by-Step Fix
1. Remove the duplicate-instance artefact
Before trusting anything a link tells you, make sure shared packages resolve to the application’s copy.
# in the library, remove dev copies of anything declared as a peer
node -e '
const peers = Object.keys(require("./package.json").peerDependencies ?? {});
console.log("remove from node_modules while linked:", peers.join(", "));
'
rm -rf node_modules/react node_modules/react-dom
An alternative that survives reinstalls is to alias the shared package in the application’s bundler config so both paths resolve to one copy:
// vite.config.ts, in the consuming app, while debugging
export default defineConfig({
resolve: {
dedupe: ["react", "react-dom"],
alias: { react: path.resolve("./node_modules/react") },
},
});
2. Use the link for iteration, not for verification
A link is the right tool for the loop: change source, rebuild, re-run the consumer’s reproduction, repeat. Keep the build running in watch mode so the loop is a single keystroke:
cd ~/work/my-library && npx tsup --watch
3. Switch to pack-and-install before believing the fix
cd ~/work/my-library
npm pack --silent # produces scope-my-library-1.4.1.tgz
cd ~/work/consumer-app
npm uninstall @scope/my-library # drops the link
npm install ~/work/my-library/scope-my-library-1.4.1.tgz
npm run reproduce
This step catches the class of bug a link structurally cannot: a fix that lives in a file excluded from files, an exports subpath that points at something the build does not emit, a postinstall script that fails outside your repository.
4. Or use a local store for a middle ground
npx yalc publish # in the library
npx yalc add @scope/my-library # in the consumer
# after a change:
npx yalc push
HAZARD PREVENTION
Symptom: A bug disappears when the package is linked and returns after publishing.
Root cause: The fix depends on a file that the link exposes and the tarball does not — typically a source file reached through an
exportspath that resolves in the working directory becausesrc/is present there.Fix: Reproduce with pack-and-install before concluding a fix works, and check
npm pack --dry-runfor the file the fix touches.
Verification
# the linked package resolves to your working directory
node -e "console.log(require.resolve('@scope/my-library'))"
# there is exactly one copy of each peer
npm ls react react-dom 2>&1 | grep -c "react@"
# the same reproduction passes against the packed tarball
npm install /path/to/scope-my-library-1.4.1.tgz && npm run reproduce
Expected: a path inside your library, a single copy of each peer, and a passing reproduction against the tarball rather than the link.
Edge Cases / Gotchas
npm linkand workspaces interact badly. Inside a monorepo the package may already be symlinked by the workspace, and adding a global link creates a second, competing link with unclear precedence.- pnpm’s
linkbehaves differently from npm’s. Its strict layout means the linked package cannot reach the application’s dependencies at all, which surfaces missing peers that npm’s hoisting hides. - Unlinking is easy to forget. A stale global link silently shadows the published package in every project that ever linked it;
npm ls --link --globallists them. - TypeScript resolves through the link too. Types come from your working directory, so a declaration error introduced but not yet built is invisible until the tarball path is exercised.
- Watch-mode rebuilds can be read mid-write. A consumer process may load a partially written file; a build that writes atomically, or a short delay before re-running, avoids chasing a phantom failure.
Frequently Asked Questions
Why does npm link produce duplicate React or other framework copies?
A linked package is a symlink to your working directory, and its own node_modules travels with it. When the framework is installed there as a dev dependency, the linked copy resolves to it while the application resolves to its own — two instances.
Does npm link test what a consumer will actually receive?
No. A link exposes the whole working directory, so excluded files are reachable and the exports map is applied against a tree containing source and tests. Pack-and-install is the only faithful method.
What is yalc and when is it better than a link?
It copies the packed contents into a local store and installs from there, giving a real directory containing only publishable files — most of the fidelity of pack-and-install with a fast loop.
Why does my linked package fail to resolve its own exports subpaths?
Because the map points at build output that may not exist yet in the working directory. With an installed tarball the build has already run, so the failure cannot occur.
Should linking be part of the release checklist?
No. It is a development loop. The release checklist should use pack-and-install, because the questions it answers are exactly the ones linking cannot.
Related
- Source Maps and Debugging Published Packages — making the traces you see during this loop readable.
- Validating Packages Before Publish — the pack-and-install rehearsal as a release gate.
- Choosing Peer Dependencies vs Dependencies for Libraries — the duplicate-instance problem in its non-linking form.