Асинхронные действия заканчиваются бесконечным циклом

Описание

Ситуация запутанная, попробую объяснить на деталях.

Часть 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 шт):

Автор решения: Grundy

Проблема в том что 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>

→ Ссылка