Skip to content

[Bug]: ESM-CJS interoperability issue of getting incorrectly imported default module #16144

Open

Description

@jmuransky

Version

30.4.1

Steps to reproduce

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'

([email protected])

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

Actual behavior

thunk is object

Additional context

No response

Environment

System:
    OS: Windows 11 10.0.22631
    CPU: (14) x64 Intel(R) Core(TM) Ultra 5 235U
  Binaries:
    Node: 24.15.0 - C:\Program Files\nodejs\node.EXE
    Yarn: 1.22.22 - C:\Users\myj\AppData\Roaming\npm\yarn.CMD
    npm: 11.12.1 - C:\Program Files\nodejs\npm.CMD

Activity

  1. SimenB commented on May 9, 2026

    @SimenB
    Member

    If I install redux-thunk@2 and have this file

    import thunk from 'redux-thunk';
    
    console.log(thunk);

    And run that with node@26, it prints

    $ node file.mjs                                                            
    {
      default: [Function: middleware] {
        withExtraArgument: [Function: createThunkMiddleware]
      }
    }

    Jest logs the same:

    Image

    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]


    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?

  2. jmuransky commented on May 9, 2026

    @jmuransky
    ContributorAuthor

    Yes. This is exactly it. That's precisely what I wanted to study these 2 days to get as correct behavior as possible

  3. SimenB commented on May 9, 2026

    @SimenB
    Member

    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 _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?

  4. jmuransky commented on May 9, 2026

    @jmuransky
    ContributorAuthor

    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

  5. jmuransky commented on May 9, 2026

    @jmuransky
    ContributorAuthor

    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

  6. SimenB commented on May 9, 2026

    @SimenB
    Member

    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

  7. jmuransky commented on May 9, 2026

    @jmuransky
    ContributorAuthor

    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?

  8. jmuransky commented on May 9, 2026

    @jmuransky
    ContributorAuthor

    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.

  9. SimenB commented on May 9, 2026

    @SimenB
    Member

    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 🙂

  10. jmuransky commented on May 9, 2026

    @jmuransky
    ContributorAuthor

    Yes. The possibility to configure jest for auto-unboxing would absolutely help

  11. SimenB commented on May 9, 2026

    @SimenB
    Member

    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 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions