Modules may need to load non-JS resources that would clearly categorized as dependencies - the module is not truly ready until those resources are (example: templates for UI components). Loading APIs, like fetch()
are asynchronous, and there is currently no way to make a module wait for asynchronous calls.
Top-level await, which would block execution within a module on a await expression, has been proposed as a way to solve this problem. It has a critical problem as highlighted here by Rich Harris.
The summary is that because top-level await block execution of modules, and modules execute serially, only one top-level await can be pending at a time - all top-level await expressions are serialized. This means that one module awaiting it's resources blocks subsequent modules from even requesting theirs, eleminating any ability to parallelize resource loading.
What we really want here is for all modules in a graph to be able to start their async initialization work without blocking, then later wait on async initialization to complete to perform their blocking top-level execution.
If we could limit top-level await to modules that somehow evaluate eagerly - that is without blocking evaluation of subsequent module in the graph - then multiple async operations could be started at once.
So one solution is to come up with a way to mark certin modules as "async"[1] - that they evaluate immediately and don't block other async modules. The module loader would travers the module graph, find all the async module, and evaluate them, then evaluate the rest of the modules in standard order as their sync and async dependencies are ready.
We could mark a module as async in a few ways:
- Some kind of pragma or keyword at the top of the file.
- A modifier on the import of the module
- Inline modules
A pragma doesn't leave a hint at the import statement that the import is async (which might not be a concern really), forces async initialization to be in a separate module, and it might make bundling more difficult. A modifier on the import means that you can't look solely at a module to tell top-level await
is allowed.
Inline modules though, would make it easy loaders and humans to see that a module allows top-level await;
It would look like this:
import {foo} from async {
// This is an inline module that allows top-level await
// It can have it's own import:
import {bar} from './bar.js';
// And, of course, top-level await
const foo = await bar();
// And exports
export {foo};
};
A common use case would be loading templates for components. That could look like this:
import {template} from async {
import {loadTemplate} from 'load-template';
export const template = await loadTemplate('./my-template.html', import.meta.url);
};
import {TemplatedHTMLELement} from 'templated-html-element';
class MyElement extends TemplatedHTMLELement {
static template = template;
}
They cannot access symbols outside their own scope.
Module evaluation order is currently pre-order in the module graph. Modules later in the graph have to wait for all modules earlier in the graph to load before being evaluated. Async modules can evaluate as soon as they, and their own subgraph, load.
Workers currently have to be defined in a separate file. But what's really important is that they're a separate module.
Imagine:
const myWorker = new Worker(module {
import {whatever} from './whatever.js';
whatever();
});
[1]: This is maybe a poor term, because they're really eager, where async in other parts of the platform implies lazy. But it's a bit like async functions in that it indicates they can have await
expressions.
I still don't think the claimed issue with top-level
await
is an issue whatsoever to begin with, the thing is if you want high performance you basically must use HTTP/2 push. It's already supported by all of the major current browsers (even IE11 on Win10) so there's decreasing reason to not use it. If you do this you can be certain to send the data even if it's currently blocked. This isn't even including other additions that are being added likeprefetch
, service workers or other things.Take your given example:
You can simply push
./my-template.html
whenever the importing file is requested, and this won't block parallel loading because every other dependency will be pushed instead of pulled.The only real down side to top-level await is that tools that generate dependency graphs from a module need to have additional logic to determine what should be pushed. For example a tool couldn't trivially determine that
./my-template.html
is a dependency of that component, but this is going to be true for any thing that allows arbitrary resources to be loaded including the non-solution of just requiring exporting Promises everywhere.Another, another solution not directly tied to JavaScript itself would just be for Node/Browsers to agree on some module specification for loading arbitrary binary blobs (or text files) e.g.:
Then you can just load your stuff and process it further synchronously, unfortunately this does prevent use-cases like loading WebAssembly as part of the ES module system (as you need to initialize it with
imports
andWebAssembly.Memory
potentially which are both async).