Repository navigation
Description
Summary
Since v11.23.0, with enableGlobalVirtualStore: true (and extendNodePath left at its default), pnpm injects a NODE_OPTIONS --import resolve hook that retries a failed bare-specifier resolution once per NODE_PATH entry, passing parentURL: <NODE_PATH entry>/x to nextResolve().
A synchronous resolve hook registered by the user is called first, with the correct parentURL. When its nextResolve() call fails and it reads context.parentURL in the catch block, the value is no longer the importer: the retry's parentURL has been left on the shared context object. Any such hook that falls back to something derived from the importer (for example a tsconfig paths lookup) now starts from <root>/node_modules and fails with ERR_MODULE_NOT_FOUND.
before nextResolve(): file:///…/packages/app/main.mjs
in catch: file:///…/node_modules/x
It works on 10.x and 11.22.0, and fails on 11.23.0 and on 12.14.0 (latest at the time of writing). I have not checked whether this is Node merging the object passed to nextResolve() back into the caller's context; either way the result is a compatibility regression for existing hooks that appears when pnpm injects its hook.
Reproduction
Layout (enableGlobalVirtualStore: true, a workspace with the app in a subdirectory, which is the usual monorepo shape):
pnpm-workspace.yaml # packages: [packages/*] + enableGlobalVirtualStore: true
package.json # { "name": "root", "private": true }
packages/app/package.json # { "name": "app", "private": true, "dependencies": { "is-odd": "3.0.1" } }
packages/app/tsconfig.json # { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }
packages/app/src/generated/index.mjs # export const x = 1
packages/app/main.mjs # import { x } from "@/generated/index.mjs"; console.log("resolved", x)
packages/app/alias-hook.mjs # see below
alias-hook.mjs is a stand-in for any loader that resolves tsconfig paths from the importer (what ts-node/tsx-style hooks do):
import { registerHooks } from "node:module";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
function findPaths(startDir) {
for (let dir = startDir, prev; dir !== prev; prev = dir, dir = dirname(dir)) {
try {
const cfg = JSON.parse(readFileSync(join(dir, "tsconfig.json"), "utf8"));
return { dir, paths: cfg.compilerOptions?.paths ?? {} };
} catch {}
}
}
registerHooks({
resolve(specifier, context, nextResolve) {
try {
return nextResolve(specifier, context);
} catch (err) {
if (err.code !== "ERR_MODULE_NOT_FOUND" || !specifier.startsWith("@/")) throw err;
const parentDir = dirname(fileURLToPath(context.parentURL));
console.log("[alias-hook] parentURL seen by hook:", context.parentURL);
const found = findPaths(parentDir);
if (!found?.paths["@/*"]) throw err;
const target = join(found.dir, found.paths["@/*"][0].replace("*", specifier.slice(2)));
return nextResolve(pathToFileURL(target).href, context);
}
},
});Run:
pnpm install
cd packages/app
pnpm exec node --import ./alias-hook.mjs main.mjsResults
| pnpm | context.parentURL inside the catch |
Result |
|---|---|---|
| 10.12.1 | file:///…/packages/app/main.mjs |
resolved 1 |
| 11.22.0 | file:///…/packages/app/main.mjs |
resolved 1 |
| 11.23.0 | file:///…/node_modules/x |
ERR_MODULE_NOT_FOUND: Cannot find package '@/generated' |
| 12.14.0 | same as 11.23.0 | same as 11.23.0 |
On 11.23.0 and later, pnpm exec node -e 'console.log(process.env.NODE_PATH, process.env.NODE_OPTIONS)' shows NODE_PATH=<root>/node_modules/.pnpm/node_modules:<root>/node_modules and a --import=data:text/javascript,…registerHooks… flag. Setting extendNodePath: false removes both and the import resolves again.
Why it matters
The affected pattern is narrow but real: a synchronous resolve hook that reads context.parentURL after nextResolve() has thrown. For instance, the TypeScript loader of the @tailor-platform/sdk CLI (2.25.0, dist/cli/ts-hook.mjs) does exactly this in its catch block to find the importer's tsconfig paths; running tailor generate in a project that imports @/generated fails with Cannot find package '@/generated' once the global virtual store is on with pnpm >= 11.23.0.
Not every loader is affected: with the same layout and tsconfig paths, tsx 4.23.15 resolves @/generated correctly on 11.23.0 and 12.14.0, both as tsx main.mjs and as node --import tsx main.mjs. The failure needs a hook that reads context.parentURL after nextResolve() has failed.
Expected behavior
When the NODE_PATH retries all fail and control returns to the caller, the original parentURL should be preserved on the context the caller sees.
As a check, a hook that copies context.parentURL into a local variable before calling nextResolve() resolves correctly on 11.23.0 and 12.14.0, so the retry's parentURL is the only thing that differs.
Workaround
extendNodePath: false, plus packageExtensions for the undeclared dependencies that the NODE_PATH fallback used to cover (the docs recommend this combination).
Environment
- pnpm 11.23.0, 11.28.5, 12.14.0 (fails); 10.12.1, 10.34.7, 11.22.0 (works)
- Node.js 24.13.0, macOS (arm64)
- Related: feat(gvs): restore NODE_PATH resolution for scripts, ESM included #13931 (introduced the hook), Global virtual store ESM loader breaks ts-node on Node.js < 22.15 #16436 (same hook, different breakage on Node < 22.15)
Activity
github-actions commentedon Oct 16, 2026
@toiroakr, are you planning to open a pull request for this? If so, reply here and a maintainer will assign the issue to you, so nobody else starts on the same fix.
[-]Global virtual store: the ESM `NODE_PATH` resolve hook retries with a synthetic `parentURL`, so later resolve hooks that read the importer's tsconfig stop working (regression in 11.23.0)[/-][+]Global virtual store: NODE_PATH fallback leaves a synthetic parentURL in synchronous resolve-hook context after failure[/+]fix(gvs): keep the importer's parentURL on the resolve hook context
fix(gvs): keep the importer's parentURL on the resolve hook context (p…
Update pnpm to v12.15.0 (#2046)
Update pnpm to v12.15.0 (#2046)