Repository navigation
Resolve bare ESM specifiers from the caller, not from quibble (pnpm support) - #123
Merged
Merged
Conversation
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
marked this pull request as ready for review
October 6, 2026 15:52
This was referenced Oct 6, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #84.
quibble.esm()andquibble.esmImportWithPath()resolved bare specifiers (package names like'is-number') from quibble's own directory instead of from the caller's. With npm's flatnode_modulesnobody noticed. Under pnpm it breaks in one of two ways. With hoisting off, it fails withCannot 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_moduleslookups,exportsmaps, conditions, and any other loaders. It does this with a dummyimport('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 toquibble.esm().That dummy
import()lives inlib/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.
quibble.esm()andesmImportWithPath()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'std.replaceEsm().import('is-number?__quibbleresolveurl=<encoded caller URL>').nextResolvewith a copy of the context whoseparentURLis the caller. Node then resolves the specifier exactly as the caller's ownimportwould. 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_modulesstand in for a pnpm layout. The tests can resolve them, but quibble'slib/can't (or, foris-number, resolves to the real copy in the rootnode_modulesinstead).The new tests cover three cases:
quibble.esm()andesmImportWithPath()).ignoreCallsFromThisFile().All four tests fail without the fix.