Неявные баги в Promise.withResolvers() при передаче управления между модулями в ESM-циклах и динамических импортахЭтот баг пропускают и TypeScript, и линтер — вы узнаете о нём только на проде.
Promise.withResolvers() (ES2024) в ESM-циклах создаёт состояние гонки, когда resolve/reject экспортируются из модуля, участвующего в циклических зависимостях.
Как работает гонкаВ ESM модули выполняются синхронно при импорте. Если модуль A экспортирует resolve, а модуль B вызывает его на этапе инициализации, resolve может быть undefined или ссылка на него может быть перезаписана после вызова. Пример:
// module-a.ts
const { promise, resolve } = Promise.withResolvers<void>();
export { promise, resolve };
// module-b.ts
import { resolve } from './module-a';
resolve(); // Вызов до завершения module-a
Проблема с динамическим импортомДинамический импорт добавляет ещё один слой неопределённости. Промис может быть уже разрешён до момента, когда импорт завершится:
// module-c.ts
const { promise, resolve } = Promise.withResolvers();
export default promise;
export { resolve };
// вызов
const module = await import('./module-c');
module.resolve('value'); // Промис уже был разрешён — вызов игнорируется
Типичные ошибки и решения* Не экспортируйте resolve/reject из модуля, который участвует в циклических зависимостях — это гарантирует частичное выполнение модуля и некорректные ссылки.
* При динамическом импорте устанавливайте тайм-аут для промиса с
Promise.race(), чтобы не зависнуть навсегда.
* Для сложной синхронизации между модулями используйте EventEmitter или очереди — они явно управляют порядком выполнения.
Бонусная ловушкаresolve/reject — обычные функции, их можно вызывать сколько угодно раз. В ESM-циклах повторный вызов просто игнорируется, но вы не заметите этого на тестах. На проде — вечно висящий промис или пропущенный reject.
Чек-лист для production:
-
Promise.withResolvers() — только внутри модуля, никогда не экспортируя resolve или reject наружу.
- Никаких асинхронных вызовов на верхнем уровне модуля, если экспортируете resolve.
- Если архитектура требует внешнего управления — используйте AbortController для отмены.
Вывод: Promise.withResolvers() безопасен только в изолированном контексте; экспорт resolve/reject в ESM-циклах превращает код в недетерминированную гонку, которую невозможно отловить статическим анализом.