tl;dr
When you resolve a promise with an object that defines a then
method "standard promise behavior" takes place. The then
method will be executed with resolve
and reject
arguments immediately. Calling then
with other values overwrites the initial promise resolution value. This behavior enables recursive promise chains.
The reasonably new import
method to load JavaScript modules is no exception to that.
Recently, two tweets covering promises and dynamic imports caught my attention. I spent two hours reading the spec, and this post shares my thought process and what I learned about promises and promise chains.
Tweet 1: A way to "kinda" hack together top-level await
Surma shared "a hack to make top-level await work".
You can include an inline script of type="module"
in your HTML which dynamically imports another module.
<script type="module">
import('./file.mjs');
</script>
The module itself exports a then
function which will be executed immediately without anything calling it.
// file.mjs
export async function then() {
// yay!!! I can use async/await here
// also yay!!! this function will be executed automatically
}
You could use this behavior to define file.mjs
as the entry point of your application and use async/await right await in the then
function.
Important detail: the then
function is executed automatically.
Tweet 2: The blocking behavior of dynamic imports
Johannes Ewald shared that dynamic imports can "block" code execution when the returned value of the import includes a then
function.
// file.mjs
export function then() {}
// index.mjs
async function start() {
const a = await import('./file.mjs');
// the following lines will never be executed
console.log(a);
}
The snippets above will never log anything.
Important detail: import('./file.mjs')
never resolves.
The promise resolution process
The behavior you saw in the examples above is not related to the import
spec (a GitHub issue describes this behavior in great detail). The ECMAscript spec describing the resolution process of promises is the foundation instead.
8. If Type(resolution) is not Object, then
a. Return FulfillPromise(promise, resolution).
9. Let then be Get(resolution, "then").
10. If then is an abrupt completion, then
a. Return RejectPromise(promise, then.[[Value]]).
11. Let thenAction be then.[[Value]].
12. If IsCallable(thenAction) is false, then
a. Return FulfillPromise(promise, resolution).
13. Perform EnqueueJob(
"PromiseJobs", PromiseResolveThenableJob, ยซ promise, resolution, thenAction ยป
).
Let's go over the possibilities to resolve a promise step by step.
Promise resolves with anything else than an object
If Type(resolution) is not Object, then return FulfillPromise(promise, resolution)
If you resolve a promise with a string value (or anything that is not an object), this value will be the promise resolution.
Promise.resolve('Hello').then(
value => console.log(`Resolution with: ${value}`)
);
// log: Resolution with: Hello
Promise resolves with an object including then
which is an abruptCompletion
Let then be Get(resolution, "then"). If then is an abrupt completion, then return RejectPromise(promise, then.[[Value]]).
If you resolve a promise with an object including a then
property which's access results in an exception, it leads to a rejected promise.
const value = {};
Object.defineProperty(
value,
'then',
{ get() { throw new Error('no then!'); } }
);
Promise.resolve(value).catch(
e => console.log(`Error: ${e}`)
);
// log: Error: no then!
Promise resolves with an object including then
which is not a function
Let thenAction be then.[[Value]]. If IsCallable(thenAction) is false, then return FulfillPromise(promise, resolution).
If you resolve a promise with an object including a then
property which is not a function, the promise is resolved with the object itself.
Promise.resolve(
{ then: 42 }
).then(
value => console.log(`Resolution with: ${JSON.stringify(value)}`)
);
// log: Resolution with: {"then":42}
Promise resolves with an object including then
which is a function
Now, we come to the exciting part which is the foundation for recursive promise chains. I started going down the rabbit hole to describe the complete functionality, but it would include references to several other parts of the ECMAScript spec. Going into the details would be out of scope for this post.
The critical part of this last step is that when a promise resolves with an object that includes a then
method the resolution process will call then
with the usual promise arguments resolve
and reject
to evaluate the final resolution value. If resolve
is not called the promise will not be resolved.
Promise.resolve(
{ then: (...args) => console.log(args) }
).then(value => console.log(`Resolution with: ${value}`));
// log: [fn, fn]
// | \--- reject
// resolve
// !!! No log of a resolution value
This defined behavior leads to the forever pending promise of the second Tweet example. resolve
is not called and thus the promise never resolves.
Promise.resolve(
{
then: (resolve) => {
console.log('Hello from then');
resolve(42);
}
}
).then(value => console.log(`Resolution with: ${value}`));
// log: Hello from then
// log: Resolution with: 42
It all ties together
Luckily the behavior shared on Twitter now makes sense to me. Additionally, it's the described behavior that you use to chain promise recursively every day.
(async () => {
const value = await new Promise((resolve, reject) => {
// the outer promise will be resolved with
// an object including a `then` method
// (another promise)
// and the resolution of the inner promise
// becomes the resolution of the outer promise
return resolve(Promise.resolve(42));
});
console.log(`Resolution with: ${value}`);
})();
// log: Resolution with: 42
A surprising edge-case
You have to be very careful when using the then
-hack, there might be a case where the resolution process leads to unexpected behavior.
Promise.resolve({
then: resolve => resolve(42),
foo: 'bar'
}).then(value => console.log(`Resolution with: ${value}`));
// log: Resolution with: 42
Even though the promise above resolves with an object including several properties all you get is 42
.
The dynamic import is no exception and following the standard promise resolution process
When you use the dynamic import
function to load JavaScript modules, import
follows the same process because it returns a promise. The resolution value of the imported module will be an object including all the exported values and methods.
For the case that you export a then
function the specified promise handling kicks in to evaluate what the overall resolution should be. The then
function can overwrite everything else that could be included in this module.
// file.mjs
export function then (resolve) {
resolve('Not what you expect!');
}
export function getValue () {
return 42;
}
// index.mjs
import('./file.mjs').then(
resolvedModule => console.log(resolvedModule)
);
// log: Not what you expect
I'll definitely avoid naming my functions then
. Finding a bug like this could take a few minutes. ๐
And that's it for today! I hope that was useful and talk soon. ๐
Top comments (0)