Skip to content

Instantly share code, notes, and snippets.

@justinfagnani
Last active January 26, 2021 08:24
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save justinfagnani/7bdf6e83dffac9db7617d3f607a66f60 to your computer and use it in GitHub Desktop.
Save justinfagnani/7bdf6e83dffac9db7617d3f607a66f60 to your computer and use it in GitHub Desktop.
Async Module Initialization with Inline Modules

Async Module Initialization with Inline Modules

The Problem

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.

The Problem with Top-Level await

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.

Safer Top-Level await

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;
}

Interesting bits

Inline modules are not lexicaly scoped.

They cannot access symbols outside their own scope.

Async modules allow for earlier evaluation of non-async modules

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.

Inline modules would be great for workers!

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.

@Jamesernator
Copy link

Jamesernator commented Dec 20, 2017

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 like prefetch, service workers or other things.

Take your given example:

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;
}

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.:

// Module specifier purely expositional
import template from "#./my-template.html"
// template available as an ArrayBuffer here

// And probably some form for plaintext
import template from "$./my-template.html"
// template available as plaintext here

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 and WebAssembly.Memory potentially which are both async).

@bmeck
Copy link

bmeck commented Dec 20, 2017

This would still block the module body from evaluating correct? I'm not seeing a significant advantage versus doing something like:

async import {whatever} from './whatever.js'; // static import that puts a binding into scope

And having top level await in the main module?

I'm not sure on the timing but it would seem that each of these "module worklets" would run in parallel above and that would make the order of evaluation non-deterministic, is that correct? This could be reproduced using a simpler top level await and a Promise.all call to reach such indeterminism too, but it seems odd to me to have static constructs be non-deterministic in order of evaluation.

Per module blocks. This problem of not wanting separate files seems to be solvable using various means, particularly URL.createObjectURL or data: URLs.

With all of the above creating nested modules in a single URL namespace, I am concerned about logically thinking about how import specifier idempotentcy, import.meta, and shared variables can be used (using globals for example in the event of non-lexical).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment