Visitar URL original
Global virtual store: NODE_PATH fallback leaves a synthetic parentURL in synchronous resolve-hook context after failure · Issue #16979 · pnpm/pnpm · GitHub
Skip to content

Global virtual store: NODE_PATH fallback leaves a synthetic parentURL in synchronous resolve-hook context after failure #16979

Closed

Description

@toiroakr

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.mjs

Results

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

Activity

  1. github-actions commented on Oct 16, 2026

    @github-actions
    Contributor

    @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.

  2. changed the title [-]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[/+] on Oct 16, 2026
  3. added a commit that references this issue on Oct 16, 2026
    84f98cc
  4. added a commit that references this issue on Oct 16, 2026
    dd95acd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions