Асинхронные действия заканчиваются бесконечным циклом
Описание
Ситуация запутанная, попробую объяснить на деталях.
Часть 1
Для начала я создаю функцию создающего Promise
вместе с AbortSignal
:
/**
* @template T
* @param {(signal: AbortSignal, resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void} callback
* @returns {Promise<T>}
*/
Promise.withSignal = async function (callback) {
const controller = new AbortController();
const { promise, resolve, reject } = Promise.withResolvers();
try {
callback(controller.signal, resolve, reject);
return await promise;
} finally {
controller.abort();
}
};
Заметьте, что функция гарантирует, что после окончания Promise
, signal
будет в состоянии aborted
.
Часть 2
Потом я создаю асинхронный confirm:
const dialogConfirm = document.querySelector(`dialog.pop-up.confirm`);
dialogConfirm.addEventListener(`click`, (event) => {
if (event.target === dialogConfirm) dialogConfirm.close();
});
/**
* @returns {Promise<boolean>}
*/
Window.prototype.confirmAsync = async function () {
dialogConfirm.showModal();
const buttonAccept = dialogConfirm.querySelector(`button.highlight`);
const buttonDecline = dialogConfirm.querySelector(`button.invalid`);
try {
return await Promise.withSignal((signal, resolve, reject) => {
buttonAccept.addEventListener(`click`, (event) => resolve(true), { signal });
buttonDecline.addEventListener(`click`, (event) => resolve(false), { signal });
dialogConfirm.addEventListener(`close`, (event) => resolve(false), { signal });
});
} finally {
dialogConfirm.close();
}
};
Будем считать что HTML прикреплен к странице
Часть 3
В след я создаю класс, который "генерирует" Promise
:
/**
* @template T
*/
class PromiseFactory {
/**
* @param {(resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void} executor
*/
constructor(executor) {
this.#executor = executor;
}
/** @type {(resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void} */
#executor;
/**
* @returns {Promise<T>}
*/
async run() {
const { promise, resolve, reject } = Promise.withResolvers();
await this.#executor.call(promise, resolve, reject);
return promise;
}
/**
* @param {(value: T) => boolean} predicate
*/
async runUntil(predicate) {
while (true) {
try {
const result = await this.run();
if (!predicate(result)) continue;
return result;
} catch {
continue;
}
}
}
}
Часть 4
И в конце концов я собираю их вместе:
/** @type {PromiseFactory<boolean>} */
const factory = new PromiseFactory(async (resolve) => resolve(await window.confirmAsync()));
factory.runUntil(result => {
console.log(result);
return result;
});
Вся суть этой конструкции была в том, чтобы создать что-то, что "повторит" Promise
пока не получит ожидаемый результат. И как бы этот код тоже должен открыть диалоговое окно до сих пор, пока вы не нажмете Accept.
Полный работающий пример:
/**
* @template T
* @param {(signal: AbortSignal, resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void} callback
* @returns {Promise<T>}
*/
Promise.withSignal = async function (callback) {
const controller = new AbortController();
const { promise, resolve, reject } = Promise.withResolvers();
try {
callback(controller.signal, resolve, reject);
return await promise;
} finally {
controller.abort();
}
};
const dialogConfirm = document.querySelector(`dialog.pop-up.confirm`);
dialogConfirm.addEventListener(`click`, (event) => {
if (event.target === dialogConfirm) dialogConfirm.close();
});
/**
* @returns {Promise<boolean>}
*/
Window.prototype.confirmAsync = async function () {
dialogConfirm.showModal();
const buttonAccept = dialogConfirm.querySelector(`button.highlight`);
const buttonDecline = dialogConfirm.querySelector(`button.invalid`);
try {
return await Promise.withSignal((signal, resolve, reject) => {
buttonAccept.addEventListener(`click`, (event) => resolve(true), { signal });
buttonDecline.addEventListener(`click`, (event) => resolve(false), { signal });
dialogConfirm.addEventListener(`close`, (event) => resolve(false), { signal });
});
} finally {
dialogConfirm.close();
}
};
/**
* @template T
*/
class PromiseFactory {
/**
* @param {(resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void} executor
*/
constructor(executor) {
this.#executor = executor;
}
/** @type {(resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void} */
#executor;
/**
* @returns {Promise<T>}
*/
async run() {
const { promise, resolve, reject } = Promise.withResolvers();
await this.#executor.call(promise, resolve, reject);
return promise;
}
/**
* @param {(value: T) => boolean} predicate
*/
async runUntil(predicate) {
while (true) {
try {
const result = await this.run();
if (!predicate(result)) continue;
return result;
} catch {
continue;
}
}
}
}
/** @type {PromiseFactory<boolean>} */
const factory = new PromiseFactory(async (resolve) => resolve(await window.confirmAsync()));
factory.runUntil(result => {
console.log(result);
return result;
});
<dialog class="pop-up confirm">
<button class="highlight">Accept</button>
<button class="invalid">Decline</button>
</dialog>
Вопрос
Проблема в том, что если нажать отказ (Decline), то начинается бесконечный цикл и, хотя программа асинхронный и не зависает, все же это неправильно.
Я никак не могу понять от чего у меня бесконечный цикл.
И ещё больше не могу понять, как исправить.
Ответы (1 шт):
Проблема в том что abort от сигнала не успевает удалить обработчик до его вызова после закрытия диалога (dialogConfirm.close();
).
Дождаться удаления можно переключившись на другой макротаск, например с помощью функции delay:
function delay() {
return new Promise(r => setTimeout(r))
}
Пример:
function delay() {
return new Promise(r => setTimeout(r))
}
/**
* @template T
* @param {(signal: AbortSignal, resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void} callback
* @returns {Promise<T>}
*/
Promise.withSignal = async function(callback) {
const controller = new AbortController();
const {
promise,
resolve,
reject
} = Promise.withResolvers();
try {
callback(controller.signal, resolve, reject);
return await promise;
} finally {
controller.abort();
}
};
const dialogConfirm = document.querySelector(`dialog.pop-up.confirm`);
dialogConfirm.addEventListener(`click`, (event) => {
if (event.target === dialogConfirm) dialogConfirm.close();
});
/**
* @returns {Promise<boolean>}
*/
Window.prototype.confirmAsync = async function() {
dialogConfirm.showModal();
const buttonAccept = dialogConfirm.querySelector(`button.highlight`);
const buttonDecline = dialogConfirm.querySelector(`button.invalid`);
try {
return await Promise.withSignal((signal, resolve, reject) => {
buttonAccept.addEventListener(`click`, (event) => resolve(true), {
signal
});
buttonDecline.addEventListener(`click`, (event) => resolve(false), {
signal
});
dialogConfirm.addEventListener(`close`, (event) => resolve(false), {
signal
});
});
} finally {
dialogConfirm.close();
await delay();
}
};
/**
* @template T
*/
class PromiseFactory {
/**
* @param {(resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void} executor
*/
constructor(executor) {
this.#executor = executor;
}
/** @type {(resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void} */
#executor;
/**
* @returns {Promise<T>}
*/
async run() {
const {
promise,
resolve,
reject
} = Promise.withResolvers();
await this.#executor.call(promise, resolve, reject);
return promise;
}
/**
* @param {(value: T) => boolean} predicate
*/
async runUntil(predicate) {
while (true) {
try {
const result = await this.run();
if (!predicate(result)) continue;
return result;
} catch {
continue;
}
}
}
}
/** @type {PromiseFactory<boolean>} */
const factory = new PromiseFactory(async(resolve) => resolve(await window.confirmAsync()));
factory.runUntil(result => {
console.log(result);
return result;
});
<dialog class="pop-up confirm">
<button class="highlight">Accept</button>
<button class="invalid">Decline</button>
</dialog>