Skip to content

vite-node: race condition in exports resulting in empty modules #7797

@privatenumber

Description

@privatenumber

Describe the bug

In native ESM, import and export statements are hoisted and fully registered before any top-level code is executed. This ensures that in cyclic dependencies, partial but correct bindings are available to dependent modules.

However, in Vite SSR / vite-node, it appears that export bindings are assigned inline, at the point in code where the export statement occurs, rather than being pre-registered before execution. As a result, in the presence of cyclic dependencies, if a module's top-level code is slow to execute, its exports may not be populated in time for the dependent module.

This causes a race condition where the importer receives an empty module, violating expected ESM semantics.

I'm currently encountering this issue while using Vitest, but the root cause appears to lie within vite-node and Vite SSR.

Reproduction

StackBlitz repro: https://stackblitz.com/edit/vitest-dev-vitest-ta6bnjew?file=README.md


This bug occurs because vite-node compiles modules such that export bindings are defined inline, rather than being hoisted like in native ESM.

For example, a.js compiles to something like:

'use strict';async (__vite_ssr_import__,__vite_ssr_dynamic_import__,__vite_ssr_exports__,__vite_ssr_exportAll__,__vite_ssr_import_meta__,require,exports,module,__filename,__dirname)=>{{const __vite_ssr_import_0__ = await __vite_ssr_import__("node:timers/promises", {"importedNames":["setTimeout"]});

__vite_ssr_dynamic_import__("/src/b.js");

await (0,__vite_ssr_import_0__.setTimeout)(1000);

const a = 1;
Object.defineProperty(__vite_ssr_exports__, "a", { enumerable: true, configurable: true, get(){ return a }});
}}

This demonstrates that the export binding for a is not registered until after the setTimeout completes. The setTimeout represents any slow top-level logic. If there's a cyclic dependency, another module may import a.js before its exports are defined, receiving an empty module.

In contrast, native ESM hoists and registers export bindings before any top-level code runs. This guarantees that the export names are accessible—even during execution of slow module code.

As an example of this behavior, you can see this with esbuild compiled code here: https://esbuild.github.io/try/#dAAwLjI1LjIALS1mb3JtYXQ9Y2pzAGNvbnNvbGUubG9nKCdoaScpCgovLyBFdmVuIHRob3VnaCBpdCBsb29rcyBsaWtlIHRoaXMgaXMgZXhwb3J0ZWQgYWZ0ZXIgY29uc29sZS5sb2cKLy8gRVNNIGJlaGF2aW9yIGlzIHRoYXQgaXQgc2hvdWxkIGdldCBleHBvcnRlZCBmaXJzdApleHBvcnQgY29uc3QgYSA9IDE7CgovLyBGWUkgaW1wb3J0cyBhbHNvIGdldCBob2lzdGVkCmltcG9ydCAnZnMn

System Info

N/A

Used Package Manager

npm

Validations

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions