Dynamic import() hooks with then()
8 min read
The concept of Promises in JavaScript long predate their addition to the language with ES2015. An early version of the Q library, one of the first to implement the concept, stems all the way back to late 2010. jQuery 1.5, released in January 2011, popularized the concept shortly after with its Deferred module. Nearly 2 years later was the Promises/A+ specification released, upon which ES2015 Promises are based.
Thenables
Read section ThenablesES5 did not include the symbol type, so the Promises specification is based around objects implementing an interface to support chaining, called thenables. Similarly to the iterator protocol, only a single function is required: then
. To support chaining, when a Promise’s resolve function is passed an object with a then
function, it is called with (resolve, reject)
functions just like new Promise
. Any subsequently returned thenables are called in a recursive fashion.
const p = new Promise((resolve, reject) =>
resolve({ then: (resolve, reject) => resolve({ then: (resolve) => resolve(1) }) })
);
void p.then(console.log);
Dynamic import
Read section Dynamic importThis behavior applies to any function returning a Promise (which it resolves through the first parameter, the resolve
function). In the case of the dynamic import()
function, used to import ESM modules dynamically, the module
it passes to resolve
can contain a user-supplied then()
function. This quirk can be used to implement import hooks.
Import hooks
Read section Import hooksIn this example, an alert is triggered when the button is clicked.
<button id="dynamicImportAlert">Import and alert</button>
<script>
dynamicImportAlert.onclick = () => {
const script = `export function then() { alert('then()'); }`;
import(`data:text/javascript,${script}`);
};
</script>
Infinitely stalling imports
Read section Infinitely stalling importsThe previous example actually stalls forever because it never calls resolve
. The example below calls then
on import
, but the alert will never show up, because the then
hook never resolves nor rejects.
<button id="dynamicImportNoop">Import and watch nothing happen</button>
<script>
dynamicImportNoop.onclick = () => {
const script = `export function then() { }`;
import(`data:text/javascript,${script}`).then(() => alert("Will never happen!"));
};
</script>
Adjusting the return value of import
Read section Adjusting the return value of importThe then
value of import
is usually the entire module containing all its imports. A then()
hook can override the return value. For example, to hide the default import in this instance:
<button id="dynamicImportAlertSetValue">Import and replace button text</button>
<script>
dynamicImportAlertSetValue.onclick = function () {
const script = `export function then(resolve) { resolve("Can't access other exports anymore"); } export default 25;`;
import(`data:text/javascript,${script}`).then((value) => (this.textContent = value));
};
</script>
this inside then
Read section this inside thenThe this
value inside then
refers to the complete module, including its own then
function. A then()
function containing just resolve(this)
would naively be considered equivalent to not having one at all, but the devil’s in the details: this.then
would be called by resolve
and result in an infinite loop.
Destructuring can be leveraged to remove the then
function from the module, in which case it’s like nothing ever happened. The caller has no way of knowing a function was actually ever called.
<button id="dynamicImportAlertDefault">Import and alert default export</button>
<script>
dynamicImportAlertDefault.onclick = () => {
const script = `export function then(resolve) { const { then, ...mod } = this; resolve(mod); } export default 25;`;
import(`data:text/javascript,${script}`).then((exports) =>
alert(`default: ${exports.default}, typeof then: ${typeof exports.then}`)
);
};
</script>
This behavior could be used to hide, override, or eagerly perform calculations when the module is imported dynamically. Static imports (import ... from '...';
) remain unaffected; import { then } from ...
demonstrates no special behavior either.
Roll the dice
Read section Roll the diceOne can imagine how this feature can be used alongside reject()
to randomly break imports. Only the most unfortunate users would receive a reject()
ion, while the majority of imports remain unchanged with resolve(this)
.
The example below is less evil in nature, while still demonstrating the concept. How unlucky are you today?
<button id="dynamicImportRoll">Roll</button>
<script>
dynamicImportRoll.onclick = function () {
const script = `export function then(resolve, reject) { if (Math.random() < 0.1) reject('Unlucky!'); else resolve(Math.floor(Math.random() * 6) + 1); }`;
import(`data:text/javascript,${script}`).then(
(text) => (this.textContent = `Rolled: ${text}`),
// `.then` can also receive the `.catch` case as second parameter
(text) => (this.textContent = text)
);
};
</script>
Workaround
Read section WorkaroundThere remains no way to bypass this behavior with dynamic import()
- all Promises are born equal. This makes it impossible to access any legitimate then()
functions, making them only available when using static imports (import X from '...'
). There used to be a proposal for a Symbol.thenable which tried to make it possible to opt-out of execution of then
. A value of false
would have resulted in then
never being called.
A complex workaround exists in the browser by leveraging the DOM and its capability to dynamically inject scripts. An example of this approach can be seen below; a slightly different approach leveraging the onload event is also possible.
<button id="dynamicImportScriptTag">Bypass then()</button>
<script>
dynamicImportScriptTag.onclick = () => {
const script = `export function then() { alert('then()'); }`;
importModule(`data:text/javascript,${script}`)
.then(({ exports }) => alert(`${typeof exports.then} then() never called`))
.catch(alert);
};
function importModule(url) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const resolveModuleName = `_${Math.random().toString(36).slice(2)}`;
function cleanup() {
delete window[resolveModuleName];
script.remove();
}
window[resolveModuleName] = function (exports) {
cleanup();
// Calling resolve(value) here would lead to the same problem as before, as it tries to resolve thenables recursively
resolve({ exports });
};
script.type = "module";
script.textContent = `import * as exports from "${url}";window.${resolveModuleName}(exports);`;
script.onerror = () => {
cleanup();
reject(new TypeError("error loading dynamically imported module"));
};
document.body.append(script);
});
}
</script>
Node.js does not contain any workarounds for this issue. CommonJS modules imported with require
are entirely unaffected, but there is no mechanism to import ESM modules in CJS modules without dynamic import()
.