Skip to content

Resolve bare ESM specifiers from the caller, not from quibble (pnpm support) - #123

Merged
rosston merged 1 commit into
mainfrom
pnpm-support
Oct 6, 2026
Merged

rosston merged 1 commit into
mainfrom
pnpm-support

Conversation

@rosston

@rosston rosston commented Oct 6, 2026

Copy link
Copy Markdown
Member

Fixes #84.

quibble.esm() and quibble.esmImportWithPath() resolved bare specifiers (package names like 'is-number') from quibble's own directory instead of from the caller's. With npm's flat node_modules nobody noticed. Under pnpm it breaks in one of two ways. With hoisting off, it fails with Cannot find package 'x' imported from …/quibble/lib/esm-import-functions.js. In a monorepo where packages need different versions of a dependency, it silently mocks a different copy than the one the caller imports.

Why it broke

To mock a module, quibble needs its exact URL, because the loader hooks match on that URL to swap in stubs. For relative paths, quibble finds the calling file from the stack trace (honoring ignoreCallsFromThisFile()) and resolves against it.

For bare specifiers, quibble asks Node to do the resolving. That means following node_modules lookups, exports maps, conditions, and any other loaders. It does this with a dummy import('is-number?__quibbleresolveurl'). The resolve hook in quibble spots the marker and lets Node resolve the specifier. The hook then throws an error carrying the resolved URL back to quibble.esm().

That dummy import() lives in lib/esm-import-functions.js, so Node resolved the specifier as if quibble had imported it. Under pnpm, quibble lives in its own isolated directory (node_modules/.pnpm/quibble@x/node_modules/quibble). From there it can't see the app's dependencies, or it sees a different copy of them.

Why the fix works

The fix makes Node resolve the specifier from the caller's location. It takes three steps.

  1. quibble.esm() and esmImportWithPath() now find the caller for bare specifiers too, the same way they already did for relative paths, ignoreCallsFromThisFile() included. That matters for wrappers like testdouble.js's td.replaceEsm().
  2. The caller's URL rides along in the marker: import('is-number?__quibbleresolveurl=<encoded caller URL>').
  3. The resolve hook decodes the URL and calls nextResolve with a copy of the context whose parentURL is the caller. Node then resolves the specifier exactly as the caller's own import would. Copying the context avoids mutating the one Node hands the hook.

Steps 2 and 3 live in the shared helpers in lib/loader-helpers.js. Both the async hooks (--loader / register()) and the Node 26+ sync hooks (registerHooks()) therefore get the same behavior. If no caller can be found, the marker carries no URL and resolution is unchanged. Mock registration, stub generation, relative-path mocking, and CommonJS mocking are untouched.

Tests

Fake packages in test/esm-lib/node_modules stand in for a pnpm layout. The tests can resolve them, but quibble's lib/ can't (or, for is-number, resolves to the real copy in the root node_modules instead).

The new tests cover three cases:

  • A package only the caller can resolve (via both quibble.esm() and esmImportWithPath()).
  • A package where quibble would otherwise mock a different copy.
  • A wrapper that calls ignoreCallsFromThisFile().

All four tests fail without the fix.

quibble.esm() and esmImportWithPath() find a bare specifier's URL by doing
a marked dummy import() that quibble's resolve hook intercepts. That
import() lives in lib/esm-import-functions.js, so Node resolved the
specifier from quibble's own directory instead of the caller's. With npm's
flat node_modules the two see the same packages, but under pnpm quibble
lives in node_modules/.pnpm/quibble@x/node_modules/quibble, so (#84):

- with hoisting off, the import fails with "Cannot find package 'x'
  imported from .../quibble/lib/esm-import-functions.js"
- in a monorepo where packages need different versions of a dependency,
  quibble can silently mock a different copy than the one the caller
  imports

Find the caller for bare specifiers the same way as for relative ones
(honoring ignoreCallsFromThisFile, which wrappers like testdouble.js's
td.replaceEsm() rely on), send its URL along in the __quibbleresolveurl
marker, and have both the async and sync resolve hooks resolve from it by
passing nextResolve a copy of the context with that parentURL. Without a
caller, the marker carries no URL and resolution is unchanged.

Tests use fake packages in test/esm-lib/node_modules, which the tests can
resolve but quibble's lib directory can't (or, for is-number, resolves to
the real copy in the root node_modules instead).
@rosston rosston self-assigned this Oct 6, 2026
@rosston
rosston marked this pull request as ready for review October 6, 2026 15:52
@rosston
rosston merged commit be5563a into main Oct 6, 2026
18 checks passed
@rosston
rosston deleted the pnpm-support branch October 6, 2026 17:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Is pnpm supported?

1 participant