Команда WebKit повідомила, що в Safari 27 функція top-level await повністю відповідає специфікації ECMAScript. Раніше частина розробників стикалася з дивними помилками типу "accessed before initialization", які виникали без видимої причини. Проблему вирішили радикально: завантажувач модулів Safari переписали з нуля, тож тепер await на верхньому рівні JavaScript-модулів можна використовувати без побоювань. Якщо раніше ви уникали top-level await, зараз саме час спробувати цю можливість знову.
Що таке top-level await
Top-level await дозволяє застосовувати await прямо на верхньому рівні модуля, надаючи ту саму зручність роботи з Promise, яку раніше мали лише асинхронні функції. Асинхронні функції перетворюють складні ланцюжки Promise на лінійний, зрозумілий код за допомогою вирази await, і тепер таку ж перевагу отримали й автори модулів. Коли виконання доходить до await, воно призупиняється до появи очікуваного значення, а керування тим часом повертається до того, хто викликав функцію. У випадку ES-модулів логіка трохи інша: якщо модуль містить top-level await, усі модулі, що імпортують його, теж чекають на завершення цього очікування. Водночас паралельні модулі в дереві залежностей, які не пов'язані з тим, що очікує, можуть продовжувати виконуватися одночасно.
Що це означає для вебу
Уже зараз можна перевірити нову поведінку на практиці. Достатньо завантажити Safari Technology Preview 251 або бета-версію Safari 27 і спробувати додати top-level await у свій веб-застосунок — воно просто працює. Оновлення стосуються не лише top-level await: завдяки перебудові завантажувача модулів на правильній основі всю систему ES-модулів у Safari тепер можна використовувати без зайвих застережень. Коли Safari 27 вийде офіційно, top-level await і ES-модулі стане можна впроваджувати в продакшн для всіх користувачів.
У чому була проблема
Top-level await — складна функція, реалізована всередині механізму завантаження модулів. Специфікація ECMAScript у розділі про модулі частину поведінки залишає на розсуд платформи — зокрема механізм отримання модулів, який на практиці браузери виконують через мережу, а середовища виконання типу Node.js чи Bun — з локальної файлової системи. Команда WebKit написала завантажувач модулів Safari дуже давно, ще за часів пропозиції WHATWG Loader (останнє оновлення — січень 2016 року), і саме на неї спиралася реалізація платформо-залежної поведінки. Це працювало на ранніх етапах існування ES-модулів, коли їхнє виконання було суто синхронним і top-level await ще не існував. Проте пізніше вийшов ECMAScript 2022, який запровадив top-level await як офіційну можливість. На той момент пропозиція WHATWG Loader вже практично втратила значення, поступившись розділу про модулі в стандарті ECMAScript, але завантажувач модулів Safari все ще базувався саме на ній. Top-level await реалізували за логікою пропозиції, яку відкинули ще до появи async/await в ECMAScript, а не за алгоритмами асинхронного виконання модулів зі стандарту. Ця розбіжність спричиняла тонкі, важковловимі помилки, які не вдавалося повністю виправити роками, попри кілька спроб. Замість того щоб далі латати фундамент, що принципово не підтримував цю можливість, команда вирішила перебудувати його правильно.
Приклад проблеми
Головною причиною багів top-level await у Safari був спосіб, яким завантажувач модулів визначав порядок завантаження та обчислення модулів. Ось приклад коду, що демонструє цю проблему:
async function load(index) {
try {
print("Importing", index);
const module = await import("./test-module.js");
print("Imported", index);
try {
print(`Keys for ${index}:`, Object.keys(module));
} catch (e) {
print("Accessing", index, "failed:", e.message);
}
} catch (e) {
print("Importing", index, "failed:", e.message);
}
}
try {
const imports = Array.from({ length: 3 }, (_, i) => {
return load(i + 1);
});
await Promise.all(imports);
} catch (e) {
print("Test failed:", e);
}
await new Promise(resolve => setTimeout(resolve, 10));
export function someFunction() { return "Hello!"; }
export const someArray = [];
Мета коду — динамічно завантажити один і той самий модуль тричі підряд, кожного разу друкуючи назви його експортів. Логічно очікувати порядок 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
Тут не так дві речі. По-перше, неправильний порядок завершення імпортів: замість очікуваних 1, 2, 3 виходить 2, 3, 1. По-друге, з'являються дивні помилки про доступ до значення до його ініціалізації. Обидві проблеми породжені однією й тією самою помилкою.
Коли завантажувач модулів уперше починає виконувати модуль із top-level await, він доходить до await і призупиняється, повертаючи керування головному модулю, який запускає другий імпорт. Проміс для другого імпорту не мав би виконатися, доки не завершиться обчислення першого імпорту, але через баг у старому завантажувачі він виконувався одразу. Через це другий імпорт завершувався першим, а оскільки обчислення відповідного модуля ще не завершилося, код друку ключів модуля намагався звернутися до неініціалізованих експортів, що спричиняло виняток. Те саме відбувалося з третім імпортом. Лише після цих двох невдалих спроб завершувався перший імпорт — на той момент обчислення вже було завершено, тож ключі модуля виводилися успішно.
З новим завантажувачем модулів результат виглядає саме так, як і повинен:
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 проти рідного C++
Старий завантажувач модулів був написаний на JavaScript як self-hosted builtin. У цього підходу були свої переваги: на відміну від рідного коду, вбудовані функції на JavaScript можна вбудовувати (інлайнити) прямо в код користувача, який їх викликає, уникаючи витрат на перехід між JavaScript і C++. Крім того, створення об'єктів із JavaScript швидше, оскільки часом можна взагалі уникнути виділення пам'яті в купі.
Проте з часом виявилося кілька суттєвих недоліків такого самохостингу: він повільніше стартує, бо код доводиться компілювати під час виконання, тоді як рідний код компілюється заздалегідь і повністю. Крім того, вбудовані функції мають дуже різноманітні шаблони використання, що ускладнює роботу оптимізаційних JIT-компіляторів JavaScriptCore, а завантажувач модулів узагалі не є "гарячим" шляхом виконання, тож переваги компіляції під час виконання там мінімальні. Через це загальна продуктивність виявляється менш стабільною і передбачуваною, ніж у випадку з C++. Переписуючи завантажувач модулів, команда вирішила відмовитися від self-hosted builtin на користь повністю рідного коду.
Як відбувалося переписування
У січні 2026 року команда розпочала переписування завантажувача модулів. Оскільки на той момент уже склався консенсус, що self-hosted JavaScript — не найкращий підхід для цього компонента, роботу почали з видалення всього JavaScript-файлу зі старим завантажувачем.
Далі операції завантажувача модулів реалізовували одна за одною, перекладаючи псевдокод зі специфікації ECMAScript на C++. Оскільки механізм завантаження модулів являє собою складний скінченний автомат, команда спершу вивчила специфікацію, зафіксувала, як функції викликають одна одну, і побудувала граф потоку виконання, щоб визначити порядок реалізації.
Прості "листові" функції, як ExecuteModule і ModuleRequestsEqual, реалізували першими, оскільки вони не залежали від інших функцій. Уже за кілька тижнів новий завантажувач модулів навчився обробляти найпоширеніші сценарії, і команда опублікувала чернетковий pull request.
Тестування нової реалізації
Оскільки мета переписування полягала саме в підвищенні надійності, ретельне тестування було критично важливим. Інженери Bun, чиє середовище виконання побудоване на JavaScriptCore і успадкувало ті самі проблеми старого завантажувача, надали зібрані ними тест-кейси, що демонстрували некоректну поведінку. Ці тести легко адаптували для роботи з командним рядком JavaScriptCore (jsc) та інтегрували в тестовий набір.
Для навантажувального тестування продуктивності й функціональності написали фазер, який генерував складні графи модулів (частина з них — із top-level await, частина без) та інструкцій імпорту. Мета полягала в перевірці коректності спостережуваних наслідків процесу завантаження модулів. Для цього генерували великі графи й порівнювали вивід JavaScriptCore з новим завантажувачем модулів із виводом інших JavaScript-рушіїв. Якщо текстовий вивід збігався побайтово з результатами інших рушіїв, це підтверджувало правильну обробку тестового випадку. І дійсно, у кожному перевіреному прикладі новий завантажувач модулів коректно обробляв дані фазера. Коли всі пов'язані з модулями тести test262 почали проходити, а також вдалося виправити чимало раніше проблемних тестів WPT без жодних регресій, новий завантажувач модулів був готовий до злиття. На той момент команда вже кілька тижнів щодня використовувала збірку Safari з новим завантажувачем модулів без жодних проблем.
Спробуйте самі
Після виходу Safari 27 розробники зможуть повноцінно використовувати переваги top-level await у своїх веб-застосунках. А до того часу можна завантажити бета-версію і спробувати інтегрувати top-level await у свої проєкти. Якщо виникнуть проблеми, команда WebKit просить залишати відгуки — повідомити про помилку можна на bugs.webkit.org.
Джерело: WebKit Blog


