then() を export した結果www

Promise と Thenable

Promise が ECMAScript の言語仕様に追加されたのは ES2015 ですが, Promise ライクなオブジェクトはそれ以前からも広く使われてきました (jQueryDeferred など). そういった Promise ライクなオブジェクトとの互換性のため, Promise の仕様は本物の Promise と Promise ライクなオブジェクトを混ぜて使えるようになっています.

具体的には, Promise ライクなオブジェクトは一般に Thenable という共通のインターフェースを持つことになっています. オブジェクトが Thenable であるために必要なのは「then() という名前のメソッドを持っている」という一点のみです.

もし Promise を解決 (resolve) するときに使われた値が Thenable であれば, その then() メソッドが onFulfilledonRejected の 2 つの関数を引数として呼び出されます. そこで onFulfilled が呼び出されればさらにその引数を使って Promise が解決され, onRejected が呼び出されれば Promise が拒否 (reject) されます.

const thenable = {
  then: (onFulfilled, onRejected) => {
    onFulfilled(42);
  },
};

// const res = await Promise.resolve().then(() => thenable); などでも一緒
const res = await Promise.resolve(thenable);
console.log(res); // => 42

Dynamic Import と Thenable

ES modules にはいわゆる通常の import (static import) の他に, import() を使った動的な import (dynamic import) が用意されています.

// static import
import * as foo from "./foo.js";

// dynamic import
const bar = await import("./bar.js");

import() は Promise を返し, この Promise は import 対象のモジュールから export される全ての値を持ったオブジェクト (module namespace object) で解決されます.

おや? ということは, もしモジュールが then() という名前の関数を export していたとしたら, module namespace object が Thenable になるので, then() が呼び出されるということになりますね.

/* foo.js */
export function then(onFulfilled, onRejected) {
  onFulfilled(42);
}
/* bar.js */
// static import であればふつうに import できる
import * as foo1 from "./foo.js";
console.log(foo1); // => [Module: null prototype] { then: [Function: then] }

// dynamic import すると then が呼ばれる
const foo2 = await import("./foo.js");
console.log(foo2); // => 42

このように import の仕方によって結果が異なることに驚きはありつつも, Promise の解決の挙動の一貫性を重視したということのようです (参考: Is it expected that module namespace objects are treated as thenables? · Issue #47 · tc39/proposal-dynamic-import).

逆に import の結果に一貫性を持たせるためには, モジュールから then() という名前の関数を export しないようにしましょう. MDN にも注意があってよく書けているなと思いました.

Warning: Do not export a function called then() from a module. This will cause the module to behave differently when imported dynamically than when imported statically.

import() - JavaScript | MDN

Vitest と遺書

でも dynamic import なんてそんなに使わないし自分には関係なさそう, と思っているそこのあなた. 実は普段から気がつかないうちに使っているかもしれませんよ.

たとえば Vitest を使ってテストを実行すると, モジュールのモックを可能にするために, 全ての static import が dynamic import に書き換えられられます (流石にライブラリの中身まで再帰的に書き換えられることはないようですが). 内部的には hoistMocksPlugin というプラグインがこの役割を担っているようです.

そのため, もしあるモジュールがうっかり then() という名前の関数を export していると, Vitest で正常にテストを実行することができなくなってしまいます. 具体的な症状としては, いつまでも then()onFulfilled を呼び出さないために実行が止まってしまうなど (参考: Vitest gets stuck during build of actor 3rd party library (named export then) · Issue #5122 · vitest-dev/vitest).

なんにせよ, then() という名前の関数を export するのは避けましょう.