JavaScript

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.

ES5 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);

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

In 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 imports

The 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 import

The 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>

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

One 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>

There 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().