Skip to content

Export stubs whose names aren't identifiers - #125

Merged
rosston merged 1 commit into
mainfrom
export-names-that-are-not-identifiers
Oct 6, 2026
Merged

rosston merged 1 commit into
mainfrom
export-names-that-are-not-identifiers

Conversation

@rosston

@rosston rosston commented Oct 6, 2026

Copy link
Copy Markdown
Member

This fixes the stub module quibble generates for a mock, so any export name works. Before, on Node 24+, td.replaceEsm('some-cjs-package') failed with SyntaxError: Unexpected token '.'. The stub couldn't handle export names that aren't JavaScript identifiers, and Node 24+ gives every CommonJS module one called 'module.exports'.

Why it broke

When you mock an ES module, quibble generates the source of a stub module that exports your stubs. It declared each named export with export let ${name} = …, so the name had to be a valid identifier.

Node 24+ reports two exports when ES module code imports a CommonJS package: default and 'module.exports'. Node 22.14 and earlier report only default. When testdouble.js's td.replaceEsm() is given no replacement, it imitates the real module and passes all of its export names to quibble.esm(). The stub then contained export let module.exports = …, which is a syntax error.

Calling quibble.esm() directly with a name like 'kebab-case' broke the same way, on any Node version.

Why the fix works

JavaScript lets a module export a binding under any string name: export { local as "any name" }. The stub now binds each stub value to a local variable and exports that under its real name, as a string:

let __quibbleExport0 = /* the stub for 'module.exports' */;
export { __quibbleExport0 as "module.exports" };

Importers see the real name, and the local name exists only inside the generated module. Each stub module has its own scope, so the locals can't clash across modules or mock generations. Because names are now JSON-encoded, quotes and backslashes in a name can't break the generated source either.

The change is all in transformModuleSource in lib/loader-helpers.js, which both the async hooks and the Node 26+ sync hooks use.

New tests

The change adds two tests in test/esm-lib/quibble-esm.test.mjs:

  • Named exports whose names aren't identifiers ('module.exports', 'kebab-case', and one with quotes and backslashes). This fails without the fix on every Node version.
  • A CommonJS module mocked with the export names Node gives it, the way td.replaceEsm() does. This fails without the fix on Node 24+.

The stub module for a mocked ES module declared each named export with
`export let ${name} = ...`, which is a syntax error when the name isn't an
identifier. Node 24+ gives CommonJS modules a 'module.exports' named
export, and testdouble.js's td.replaceEsm() passes every export of the
real module along when imitating it, so td.replaceEsm('some-cjs-package')
failed on Node 24+ with "SyntaxError: Unexpected token '.'".

Bind each stub to a local and export it under its name as a string
(`export { __quibbleExport0 as "module.exports" }`), which works for any
name. The name is also JSON-encoded when looking its stub up, so quotes
and backslashes in it no longer break the generated source either.
@rosston rosston self-assigned this Oct 6, 2026
@rosston
rosston marked this pull request as ready for review October 6, 2026 18:33
@rosston
rosston merged commit f3eec9a into main Oct 6, 2026
18 checks passed
@rosston
rosston deleted the export-names-that-are-not-identifiers branch October 6, 2026 18:34
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.

1 participant