You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Run jest with --experimental-vm-modules on codebase with strongly mixed esm and cjs dependencies.
Upon jest executing this line:
import thunk from 'redux-thunk'
I am getting object like this:
{__esModule: true, default: ƒ}
Instead of default function.
Source of the issue was found within this MR (which created the regression for me): #16143 (comment)
Expected behavior
I expect thunk variable to contain default function (the same way as is executed in browser handled by webpack/craco), so that my test executes the same way as my browser does
As an aside, if updating to redux-thunk@3 which ships native ESM both Node and Jest errors with the same The requested module 'redux-thunk' does not provide an export named 'default'. And if using the named export import {thunk} from 'redux-thunk'; both print [Function: middleware]
The conclusion is that no bundler actually does the interop at runtime - it's always either the code transform (Babel/SWC/TS) injecting _interopRequireDefault at every call site when compiling to CJS, or the bundler resolving to the native ESM build via the module field (where no interop is needed since it's a real export default).
Jest has two gaps here. First, it doesn't resolve via module by default (fine - that's correct Node behavior). Second, even if you configure a custom resolver to prefer the module field (https://jestjs.io/docs/configuration#resolver-string), Jest still treats es/index.js as CJS because shouldLoadAsEsm() only looks at the file extension and the nearest package.json"type" field - it doesn't know how the file was resolved. So cjs-module-lexer runs on a file with export syntax and explodes.
extensionsToTreatAsEsm: ['.js'] doesn't help either - Jest explicitly blocks .js from that option since it's supposed to be inferred from the nearest package.json.
So the interop gap with --experimental-vm-modules is specifically for people who were previously relying on Babel/TS compiling their test files to CJS (which injected the unwrapping helpers). When they switch to native ESM, no transform rewrites the import, so nothing does the unwrapping.
Now, the questions is - what should we do about that in Jest? I am less leaning towards the interop stuff. Maybe we can allow people to somehow configure shouldLoadAsEsm?
Well. I think I am out of my league here with regards to your knowledge.
But given what you wrote, I would think about tweaking resolution based on all other less common package.json fields. This should go rather quickly given the file already gets looked up, read and even parsed.
Still. I don't know if that's enough
If that doesn't work, then giving option to configure the behavior is nice. But I am afraid, that nobody will know about said config and there's no good way to inform user about it.
Which means that for this to be effective, jest would need to sniff the information out on it's own. And I don't know if you want such a code be part of your codebase 😐
We could use https://github.com/guybedford/es-module-lexer (it has hasModuleSyntax) when the CJS lexer throws parsing errors to either magically import as real ESM or throw an error pointing to some config change a user can do. You already added that from the regular CJS path - this would essentially just mirror that over to the CJS-from-ESM path. You'd still need to configure the resolver to look up module, but still
The issue from my point of view is, that current behavior does not throw on jest and it also does not throw compile-time.
It throws in application runtime (so not as a parsing error), because it tries to call "object". I don't see how to fix this without calling the same module all over again - which is bound to create side-effects. I think this would be an issue no matter which behavior is default (unboxing or keep it as is).
Like at this point I don't have to worry about my source code. The things that I use have newer versions. But these things are bound to come up for projects that use older (e.g. private) dependencies.
Yeah, that is an issue. But then at least there'd be a path for them. I'll call it out in the esm docs which people hopefully read if they are going esm native and hits issues. Many many projects had to update their code for exports, so hopefully if people wanna go from cjs to native esm they'll also update some dependencies (like redux-thunk to v3).
What I am proposing now should be done regardless to complete the esm fallback you started on. The fact that also at least unblocks users that hits the problem in this issue is a nice bonus 🙂
Activity
SimenB commentedon May 9, 2026
If I install
redux-thunk@2and have this fileAnd run that with node@26, it prints
Jest logs the same:
As an aside, if updating to
redux-thunk@3which ships native ESM both Node and Jest errors with the sameThe requested module 'redux-thunk' does not provide an export named 'default'. And if using the named exportimport {thunk} from 'redux-thunk';both print[Function: middleware]So our behaviour here is aligned with Node.js which I think is the correct default. So the ask here is to add the default interop, right? Like Babel's https://github.com/babel/babel/blob/8456317bc6ef8f3b39ec9e18ab3df9ec44a17e26/packages/babel-helpers/src/helpers/interopRequireDefault.ts (we have our own copy of this already: https://github.com/jestjs/jest/blob/746f2a0f57c56e3bba555280f0587d40f3db95c0/packages/jest-util/src/interopRequireDefault.ts).
If yes, I think the correct path forward is some sort of configuration that allows users to choose whether to apply these interop transforms on cjs-in-esm (maybe https://github.com/babel/babel/blob/8456317bc6ef8f3b39ec9e18ab3df9ec44a17e26/packages/babel-helpers/src/helpers/interopRequireWildcard.ts as well?). TypeScript has their
"moduleResolution": "bundler"- maybe an approach similar to that makes sense? This isn't resolution per say, but still. I'll give the exact API a think 🙂Does that match what you are asking @jmuransky?
jmuransky commentedon May 9, 2026
Yes. This is exactly it. That's precisely what I wanted to study these 2 days to get as correct behavior as possible
SimenB commentedon May 9, 2026
Had Claude try to dig out what bundlers do. Here is a repository with a bunch of tests: https://github.com/SimenB/esm-interop-lab.
The conclusion is that no bundler actually does the interop at runtime - it's always either the code transform (Babel/SWC/TS) injecting
_interopRequireDefaultat every call site when compiling to CJS, or the bundler resolving to the native ESM build via themodulefield (where no interop is needed since it's a realexport default).Jest has two gaps here. First, it doesn't resolve via
moduleby default (fine - that's correct Node behavior). Second, even if you configure a custom resolver to prefer themodulefield (https://jestjs.io/docs/configuration#resolver-string), Jest still treatses/index.jsas CJS becauseshouldLoadAsEsm()only looks at the file extension and the nearestpackage.json"type"field - it doesn't know how the file was resolved. Socjs-module-lexerruns on a file withexportsyntax and explodes.extensionsToTreatAsEsm: ['.js']doesn't help either - Jest explicitly blocks.jsfrom that option since it's supposed to be inferred from the nearestpackage.json.So the interop gap with
--experimental-vm-modulesis specifically for people who were previously relying on Babel/TS compiling their test files to CJS (which injected the unwrapping helpers). When they switch to native ESM, no transform rewrites theimport, so nothing does the unwrapping.Now, the questions is - what should we do about that in Jest? I am less leaning towards the interop stuff. Maybe we can allow people to somehow configure
shouldLoadAsEsm?jmuransky commentedon May 9, 2026
Well. I think I am out of my league here with regards to your knowledge.
But given what you wrote, I would think about tweaking resolution based on all other less common package.json fields. This should go rather quickly given the file already gets looked up, read and even parsed.
Still. I don't know if that's enough
jmuransky commentedon May 9, 2026
If that doesn't work, then giving option to configure the behavior is nice. But I am afraid, that nobody will know about said config and there's no good way to inform user about it.
Which means that for this to be effective, jest would need to sniff the information out on it's own. And I don't know if you want such a code be part of your codebase 😐
Well this is surprisingly tough decision
SimenB commentedon May 9, 2026
We could use https://github.com/guybedford/es-module-lexer (it has
hasModuleSyntax) when the CJS lexer throws parsing errors to either magically import as real ESM or throw an error pointing to some config change a user can do. You already added that from the regular CJS path - this would essentially just mirror that over to the CJS-from-ESM path. You'd still need to configure the resolver to look upmodule, but stilljmuransky commentedon May 9, 2026
The issue from my point of view is, that current behavior does not throw on jest and it also does not throw compile-time.
It throws in application runtime (so not as a parsing error), because it tries to call "object". I don't see how to fix this without calling the same module all over again - which is bound to create side-effects. I think this would be an issue no matter which behavior is default (unboxing or keep it as is).
Or did I misunderstand your proposal?
jmuransky commentedon May 9, 2026
Like at this point I don't have to worry about my source code. The things that I use have newer versions. But these things are bound to come up for projects that use older (e.g. private) dependencies.
SimenB commentedon May 9, 2026
Yeah, that is an issue. But then at least there'd be a path for them. I'll call it out in the esm docs which people hopefully read if they are going esm native and hits issues. Many many projects had to update their code for
exports, so hopefully if people wanna go from cjs to native esm they'll also update some dependencies (likeredux-thunkto v3).What I am proposing now should be done regardless to complete the esm fallback you started on. The fact that also at least unblocks users that hits the problem in this issue is a nice bonus 🙂
jmuransky commentedon May 9, 2026
Yes. The possibility to configure jest for auto-unboxing would absolutely help
SimenB commentedon May 9, 2026
See #16152. I'm busy now, but might be able to revisit later tonight or tomorrow night. If not I'll land it next week 🙂