Update – in the comments below, @bmeck clarifies that statically imported modules do evaluate in a deterministic sequential order, contra the Hacker News comments that prompted this gist.
Another follow-up to Top-level await is a footgun. On the Hacker News comments, Domenic says this:
(The argument also seems to be predicated on some fundamental misunderstandings, in that it thinks everything would have to be sequentialized. See my other replies throughout this thread.)
In other words, if you have an app like this...
// main.js
import './foo.js';
import './bar.js';
// foo.js
await delay( Math.random() * 1000 );
polyfillSomeFeature();
// bar.js
await delay( Math.random() * 1000 );
useThePolyfilledFeature();
...then the order is random, breaking any guarantees you might have thought you had about the order of execution.
Let's talk about another way that could break your app.
It's not uncommon for cyclical dependencies to exist between modules:
// A.js
import B from './B.js';
export default class A {
b () {
return new B();
}
}
// B.js
import A from './A.js';
export default class B extends A {
a () {
return new A();
}
}
Note that the cyclical dependency is assymetrical – while A.js
depends on B.js
, B.js
can't even execute until A.js
has executed. This means you have to be careful about which module gets imported first:
// main.js
import A from './A.js';
import B from './B.js';
Modules are loaded and evaluated in a specific order. In this case, main.js
first imports A.js
. Before A.js
can evaluate, its dependency B.js
must be loaded and evaluated. The dependency on A.js
is ignored because that module is already in the loading phase, so its exports are undefined – which means that B.js
, which can't execute without A.js
, fails altogether, and your app blows up.
But because that happens deterministically, it's possible to track down the error and fix it:
// main.js
import B from './B.js';
import A from './A.js';
Now let's throw some asynchronous fairy dust on this whole thing:
// main.js
import A from './foo.js';
import B from './bar.js';
// foo.js
await delay( Math.random() * 1000 );
const A = await import( './A.js' );
export default A;
// bar.js
await delay( Math.random() * 1000 );
const B = await import( './B.js' );
export default B;
Just like that, your entire app has become vulnerable to a race condition (which in a less contrived situation would be a lot more sneaky).
While it's true that you could blame both situations on the developer (bar.js
in the first example should have made its dependency on the polyfill explicit, even though that's not generally how it works, and you could engineer the same race condition with the cyclical dependencies without await
), the point here is that we've just made the behaviour of our programs much harder to reason about. These are just two simple examples of the confusion that could arise – now extrapolate that to a large application with many dependencies.
...which is that top-level await by its very nature slows down your app startup. It just mitigates it. So you have three choices:
- Top-level
await
that slows your app down and has crazy non-deterministic execution - Top-level
await
that slows your app down even more, but at least runs in a predictable way - Don't allow top-level
await
into the language
@Rich-Harris yes, they would be deterministic if loaded as a single graph / not in parallel. Otherwise you could be working with partially initialized modules. It is mentioned because it is the consumer that decides if it was blocking the initial app code that began loading the thing that
await
s. By the time that theawait
occurs, the entire dependency graph is parsed and linked. All new imports/awaits are behavior that cannot change the shape of the dep graph. Work done here can request external resources, and in fact that is the main purpose of dynamic imports in general.As per the alternative, I don't think resolution hooks are currently in the path for the
type=module
specification. The talks the Node CTC also only has very limited ability to interact with resolution. Your solution only solves one aspect of when to use top level await, and that is for known imports to map to new files. It also requires the entire application and dependency tree be able to export a single config for setting up those mapping prior to any loading operation.This causes some problems of separation of concern in my mind and attempts to use configuration to change the behavior of a problem in non-trivial ways. It would seem harder to debug/profile than awaits since the logic of the imports become implicit. It also does not solve the problem of event based awaits where the program cannot continue the current control flow until an event finishes. Additionally, this does not allow for conditional imports like ones importing based upon environment (OS/form factor/development/etc.) to be treated as a first class citizen.
When thinking of
await
I would consider it an answer to how to depend on non-JS, non-static, or non-sync resources that must exist in order for the program to execute. A great example of this is a server not being able listen to a port until a DB is being hooked up, or a website waiting on translations prior to rendering components. The uses are varied, and summarizing them as only useful for changing the mappings which imports are pulling from does not cover most of the ones I personally would use it for.