9月2日、Kai Tamkunが「Fixing Top-Level Await in Safari」と題した記事を公開した。SafariのTop-Level Awaitにおける長年のバグを根本解決するため、WebKitのモジュールローダーをゼロから書き直した経緯と技術的詳細が紹介されている。
何年も直せなかったバグが、ついに修正された
Safari 27で、JavaScriptのTop-Level Awaitが仕様通りに動作するようになった。フロントエンドエンジニアなら一度は遭遇したことがある「Cannot access 'xxx' before initialization」という謎のエラー——その原因がようやく根本から修正された。
修正の方法は「パッチ当て」ではない。WebKitチームはSafariのモジュールローダーを丸ごと書き直した。Top-Level AwaitはECMAScript 2022で正式標準化されて以来4年が経過しており、ChromeやFirefoxでは当初から正常動作していた。Safariだけが複数回の修正を試みても根治できず、互換性の問題として長らく開発者を悩ませてきた。
なぜバグが起きていたのか
Top-Level Awaitとは、ES Moduleのトップレベルでawaitを使える機能だ。通常のasync関数と同様にPromiseを扱えるようにするもので、ECMAScript 2022で正式に導入された。あるモジュールがTop-Level Awaitで停止している間、それをimportする側のモジュールも待機するが、依存関係のないモジュールは並行して実行を続けられる。
問題の根は、SafariのモジュールローダーがWHATWG Loader提案(最終更新:2016年1月)をベースに書かれていた点にある。この提案はECMAScript標準に取って代わられ実質的に廃止されたが、Safariの実装はその古い仕様に依存し続けた。ECMAScript 2022でTop-Level Awaitが追加されたとき、Safari側の実装は非同期モジュール実行のアルゴリズムに対応できておらず、複数回の修正を試みたが根本的な解決には至らなかったという。
具体的に何が壊れていたか
記事中のコード例が問題を端的に示している。同一モジュールを3回動的にimportし、モジュールのexport名を順番に表示するという単純なケースだ。
// test-module.js
await new Promise(resolve => setTimeout(resolve, 10));
export function someFunction() { return "Hello!"; }
export const someArray = [];
// main.js(抜粋)
const imports = Array.from({ length: 3 }, (_, i) => load(i + 1));
await Promise.all(imports);
期待される出力は「1→2→3」の順で各モジュールのキーが表示されることだが、旧モジュールローダーでは以下のようになっていた:
Importing 1
Importing 2
Importing 3
Imported 2
Accessing 2 failed: Cannot access 'someArray' before initialization.
Imported 3
Accessing 3 failed: Cannot access 'someArray' before initialization.
Imported 1
Keys for 1: someArray,someFunction
完了順が「2→3→1」になり、かつ2と3でエラーが発生している。 原因は、2回目・3回目のimportのPromiseが本来待つべき1回目の評価完了を待たずに即座にresolveしてしまうバグだった。まだ初期化途中のexportにアクセスしようとして例外が発生していた。
新モジュールローダーでは出力が正しくなる:
Importing 1
Importing 2
Importing 3
Imported 1
Keys for 1: someArray,someFunction
Imported 2
Keys for 2: someArray,someFunction
Imported 3
Keys for 3: someArray,someFunction
書き直しの技術的な判断
旧モジュールローダーはJavaScriptのセルフホスト型ビルトイン(JavaScriptエンジン内部をJavaScript自身で実装する手法)として書かれていた。インライン展開が可能でオブジェクト生成が速いといった利点があるが、以下の欠点が判明していた:
- ネイティブコードと違い実行時コンパイルが必要で、起動が遅い
- モジュールローダーはホットパスではないため、JITコンパイルの恩恵が小さい
- 最適化JITが利用パターンを認識しにくく、パフォーマンスが安定しない
2026年1月、WebKitチームは旧モジュールローダーのJavaScriptファイルを丸ごと削除することから作業を開始した。その後、ECMAScript仕様の疑似コードをC++に翻訳する形で関数を一つずつ実装。依存関係のないリーフ関数(ExecuteModuleやModuleRequestsEqualなど)から着手し、モジュール読み込みの状態機械全体をフローグラフで整理しながら進めた。

テストの徹底ぶり
信頼性を担保するため、テストは多層的に行われた。
- Bunエンジニアからのテストケース提供:BunはJavaScriptCoreをベースに構築されたNode.js代替ランタイムであり、同じWebKitのモジュールローダーに依存するため同じバグを抱えていた。そのエンジニアたちが再現ケースを提供し、修正の検証に協力した
- ファジングテスト:複数モジュールの複雑な依存グラフを自動生成するファザーを自作し、他のJSエンジンの出力とバイト単位で比較した
- test262・WPT:TC39の公式テストスイートtest262の全モジュール関連テストをパスさせ、Web Platform Testsの多くの失敗テストも修正した
マージ前の数週間、新モジュールローダーを組み込んだSafariのビルドを実際に日常利用し、問題がないことを確認した上でリリースに臨んでいる。
今すぐ試せる
Safari 27およびSafari Technology Preview 251で確認できる。Top-Level AwaitとES Modulesをプロダクション環境で安心して使えるようになった。問題があればbugs.webkit.orgへの報告が求められている。
詳細はFixing Top-Level Await in Safariを参照していただきたい。