-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Incorrect import order with code splitting and multiple entry points #399
Comments
As far as I know the execution order of imported ECMAScript modules is undefined (see also this question). The run-time is free to, for example, asynchronously download all imports in parallel and evaluate them in the order that their downloads finish, which could be different on different runs. The only guarantee is that all imports will be evaluated before any code in the script doing the importing is evaluated. Using ECMAScript module imports means having to write your code to be robust to different import execution orders. The bundling algorithm in esbuild takes advantage of this flexibility to do the above transformation. If you need deterministic order of imported code, you can either use |
Module evaluation in ECMA-262 is synchronous from beginning to end. It is defined strictly as a post-order graph execution in https://tc39.es/ecma262/#sec-innermoduleevaluation. There isn't any room for misinterpretation of this and no JS engines violate this behaviour. What is not synchronous is the instantiation phase which means that a top-level execution job has task queue yielding before execution begins. The confusion in eg that Stack Overflow thread is because it is not a functional execution like There should be no task queue operations triggered during the entire top-level execution operation once it starts. The only case a module graph executes asynchronously is if one or more of the modules in that graph use top-level await. In such a case, the first top-level await acts as a sort of yield for that level of the dependency tree. The full graph is still executed synchronuosly up to this first top-level await in the post-order traversal, and resumes as a callback and synchronously after it with dependencies queues in parallel for this waiting. The sync post-order execution is the start of the semantics though, and top-level await was added very much as an additional adjustment to this algorithm without loosening any of these strong constraints which was one of the key goals for its specification. |
Thanks for the additional detail. I have personally struggled to find information about how this works, and that Stack Overflow question was pretty much the only reference I could find (I personally find that part of the specification incomprehensible). To clarify: are you saying the Stack Overflow answer is simply incorrect? If so I need to do an audit of the bundler and I may need to rework more than just this, since this is the mental model I've been working with while writing the bundler.
Does that mean transforming ECMAScript modules to AMD is an incorrect transformation? I'm not too familiar with AMD but I believe it has the semantics I originally described, not these semantics. |
Yes! The specification linked there is an implementation of the tarjan strongly connected components algorithm, which is the missing background if wanting to decipher it.
I think you might be right here that AMD does not specify timing at all and RequireJS for example does not seem like it will wait to execute everything as a single top-level job, but rather executes dependencies whenever they are ready. This is certainly not the only lossy aspect semantically though. |
Also to clarify, the only reason the spec has to use Tarjan is to ensure that the module registry loading states for a cycle transition together and do not leave partial states for "strongly connected components". Apart from that it's just a standard depth-first post-order execution. Using Tarjan did turn out to be useful in detecting the strongly connected components when it came to top-level await though since top-level await and cycles actually turned out to need all this information in managing completion handling. |
I'm in the middle of working on another iteration of the code splitting feature and I just discovered what could be considered another ordering bug. Currently However, there are definitely going to be cases where people annotate impure code with I'll have to make this side effect boolean a three-state value:
Just documenting this here for later. Back to the drawing board. Edit: Interesting. This could potentially lead to dead code when combined with code splitting because this means it's no longer valid to extract statements containing Edit 2: Actually Terser does sometimes reorder function calls1 marked as 1 For example, this code will be minified to if (/* @__PURE__ */ a()) return (/* @__PURE__ */ b())(c)
else return (/* @__PURE__ */ b())(d) |
I just discovered that I think Rollup also has similar ordering bugs with code splitting. Here is an example (link): // main.js
import './lib2'
import './lib1'
if (window.lib !== 'lib1') throw 'fail' // main2.js
import './lib1'
import './lib2'
if (window.lib !== 'lib2') throw 'fail' // lib1.js
window.lib = 'lib1' // lib2.js
window.lib = 'lib2' With this input Rollup generates this code, which incorrectly causes the second entry point to throw: // output/main.js
import './lib1-4ba6e17e.js';
if (window.lib !== 'lib1') throw 'fail' // output/main2.js
import './lib1-4ba6e17e.js';
if (window.lib !== 'lib2') throw 'fail' // output/lib1-4ba6e17e.js
window.lib = 'lib2';
window.lib = 'lib1'; I wonder if this is a known bug with Rollup or not. A quick search of existing issues didn't uncover anything. My plan for handling this in esbuild is to make code in non-entry point chunks lazily-evaluated (wrapped in callbacks) and then have the entry points trigger the lazy evaluation (call the callbacks) in the right order. This has the additional benefit of avoiding conflating the binding and evaluation phases in non-esm output formats. If evaluation is deferred, then all binding among chunks happens first followed by all evaluation afterward. Another possible approach could be to generate many more output chunks for each unique atomic piece of code. But that seems like it'd be undesirable because it would generate too many output chunks. I would also still have to deal with the binding/evaluation issues for non-esm output formats if I went with that approach. |
I'm deep in the middle of working on this at the moment. I just discovered an issue with my approach. It's a problem similar to the one I discovered in #465: external imports (imports to code that is not included in the bundle) must not be hoisted past internal code or the ordering of the code changes, which is invalid. The problem is that this requires the generation of extra chunks instead of just generating a single chunk of code. Consider this input file: import {a} from './some-internal-file.js';
import {b} from '@external/package';
console.log(a, b); It would be invalid to transform that to something like this: import {b} from '@external/package';
let a = /* ... code from ./some-internal-file.js ... */;
console.log(a, b); That reorders the side effects of the internal file past the side effects of the package. Instead you have to do something like this for correctness: import {a} from './chunk.HSYX7.js';
import {b} from '@external/package';
console.log(a, b); where I have been just doing what other bundlers do so I haven't considered this case before. But other bundlers such as Rollup appear to get this wrong too. This also gets massively more complicated with top-level await and different parallel execution graphs. Not sure what to do about this new level of complexity yet. |
noticed on around v0.17.7 the behavior seems to have changed and some of the imports orders seem to work now when using code splitting. Is this issue fixed/partly fixed somehow? |
@tmcconechy Not sure about other cases, but
However, there’s actually a workaround for this issue (which we’re using at Framer as well). If you convince esbuild to code-split the code that lives in entrypoints, you’ll get a chance to order things correctly. Like, let’s take the repro above. Right now, it throws an error. But if you add one more file: // entrypoints-workaround.js
import "./init-dep-1.js";
import "./init-dep-2.js"; and make that file the first entrypoint (first is important): $ esbuild --bundle --outdir=build --format=esm --splitting --platform=node --out-extension:.js=.mjs ./entrypoints-workaround.js ./entry1.js ./entry2.js then esbuild will move $ node ./build/entry1.mjs
foo.log() (from entry 1) called Note that you don’t actually need to load the produced |
I used inject with the problematic files as a temporary workaround, not sure if it's the best way, but it's simple enough and work for my use case. |
@damienmortini whats "inject"? I do like @iamakulov workaround but for me we have hundreds of endpoints so tricky to get that right. |
Slightly related: There should exist an option that disables Example warning:
|
Also faced with a similar problem. I have a code:
I build these files via command: I also tried The resulting log is:
So the problem is that I do not have dotenv configured inside dependency, because dependency is initialized before. This is applicable to any other scenario with dependencies. |
I'm arriving at this issue also because of import order bugs, so what is the take on this issue?
|
What is the current status and is there an ETA for a fix? |
Facing the same problem, we are postponing the use of esbuild in our monorepo for the moment until there is a solution for this |
Put a comment |
Looking for a solution as well! The latest version of sentry.io SDK requires init to be called before the rest of the code and currently esbuild is still treating import sentryStuff;
init(...);
import "./index.js" as import sentryStuff;
import "./index.js"
init(...); Which breaks sentry's auto-instrument |
you can import and initialize sentry in html file |
I'm running the code in a backend environment (NodeJS). For the workaround, I just replace import with require, but I still like it to be import, mostly so that the transpiler can "optimize" the import. |
<!-- Thank you for contributing! --> ### Description This feature try to solve the execution order problem caused by rollup and esbuild's code splitting logic. The nature of these problem is that the execution order of shared modules are hoisted unexpectedly. - evanw/esbuild#399 - evanw/esbuild#2598 This is done by adding helper function to simulate the original the semantic esm input while keep the static linking between modules. <!-- Please insert your description here and provide especially info about the "what" this PR is solving -->
Maybe you should try this open source project nodestack |
@rockyshi1993 how would that help? It seems it uses |
Problems will only occur if multiple entries are configured at the same time. If you use a server-side rendered page to configure a single entry point, you can completely ignore the current problem |
For the workaround, see #399 (comment).
Consider the following code:
When you bundle this code with
esbuild --bundle --outdir=build --format=esm --splitting --platform=node --out-extension:.js=.mjs ./entry1.js ./entry2.js
, ESBuild discovers thatrun-dep.js
is a module shared between both entry points. Because code splitting is enabled, ESBuild moves it into a separate chunk:However, by doing so, ESBuild puts
run-dep.js
above theinit-dep-1.js
orinit-dep-2.js
code. This changes the import order – and breaks the code:The text was updated successfully, but these errors were encountered: