-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Description
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/AUsed Package Manager
npm
Validations
- Follow our Code of Conduct
- Read the Contributing Guidelines.
- Read the docs.
- Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
- Check that this is a concrete bug. For Q&A open a GitHub Discussion or join our Discord Chat Server.
- The provided reproduction is a minimal reproducible example of the bug.