Пользователь выбирает файл A, затем B. Заголовок показывает B, редактор — содержимое A. Оба чтения вернули верные данные. Проблема в том, что результат A пришёл после смены выбора и ничто не помешало его показать.

Цепочка «клик, загрузка, отрисовка» не объясняет ошибку: оба клика проходят те же функции. Нужно знать начало и завершение запросов и право обновлять экран. Разберём небольшой JavaScript-пример, включая ошибки и загрузку, которые часто пропускают в быстром исправлении.

Прочитайте до первого await

Вот упрощённый читатель файла. load возвращает промис текста, а publish объединяет правку с видимым состоянием. Обе функции передаются аргументами, поэтому пример не зависит от UI-фреймворка:

export function createUnsafeReader(load, publish) {
  return async function select(file) {
    publish({ file, text: '', error: null, loading: true });
    try {
      const text = await load(file);
      publish({ text });
    } catch (error) {
      publish({ error: error.message });
    } finally {
      publish({ loading: false });
    }
  };
}

До await функция записывает выбор и очищает старый текст. Пока она ждёт, может начаться другой вызов select. После await ничто не проверяет, соответствует ли запрос текущему экрану.

Локальная file не меняется во время ожидания, но общее представление меняется. В этом вся ошибка: данные верны для своего вызова, но показывать нужно уже другой.

Нарисуйте временную шкалу

Возьмите два управляемых чтения и завершите B раньше A:

Шаг Событие Выбранный файл Видимый текст Загрузка
1 Выбор A; запуск чтения A A пусто да
2 Выбор B; запуск чтения B B пусто да
3 Успех B B текст B нет
4 Успех A B текст A нет

Оба вызова выполняли один код. Из-за порядка победило последнее завершившееся чтение, хотя должен был победить последний выбранный файл.

До исправления уточните поведение интерфейса. Одна панель предпросмотра должна показывать последний выбор. Пакетному импорту нужны все результаты, очереди сохранения может быть важен порядок записей; отбрасывать старые результаты там неверно. Исправление ниже — для вида, где новый выбор заменяет старый.

Воспроизведите без таймеров

Тест с ожиданием 100 мс для A и 10 мс для B медленный и зависит от планирования. Отложенный промис позволяет тесту точно выбирать момент завершения:

function deferred() {
  let resolve, reject;
  const promise = new Promise((yes, no) => {
    resolve = yes;
    reject = no;
  });
  return { promise, resolve, reject };
}

Создайте два отложенных чтения, выберите оба файла, завершите B, затем A:

const a = deferred(),
  b = deferred();
const state = {};
const select = createUnsafeReader(
  (file) => (file === 'A' ? a : b).promise,
  (patch) => Object.assign(state, patch),
);
const first = select('A'),
  second = select('B');
b.resolve('B text');
await second;
a.resolve('A text');
await first;
assert.equal(state.file, 'B');
assert.equal(state.text, 'A text'); // Reproduces the defect.

Последнее утверждение намеренно проверяет ошибочный результат, доказывая наличие сбоя. Тест исправленного читателя ожидает текст B. Отдельные тесты воспроизведения и исправления не дают спутать зелёное воспроизведение с доказательством исправности старого кода.

Присвойте каждому выбору номер

Для одного вида достаточно счётчика. Увеличьте его при начале выбора, сохраните значение в вызове и сравнивайте перед любой публикацией:

export function createReader(load, publish) {
  let latest = 0;
  return async function select(file) {
    const request = ++latest;
    publish({ file, text: '', error: null, loading: true });
    try {
      const text = await load(file);
      if (request === latest) publish({ text });
    } catch (error) {
      if (request === latest) publish({ error: error.message });
    } finally {
      if (request === latest) publish({ loading: false });
    }
  };
}

Счётчик принадлежит экземпляру читателя. Глобальный счётчик заставил бы клик в одном окне отбросить результат другого. Созданный внутри select, он сбрасывался бы каждый раз и не различал запросы.

Сравнения путей тоже недостаточно. При выборе A, затем B, затем снова A первый и третий запросы имеют один путь, но первый может содержать старую версию. Номер запроса различает их.

Пример предполагает синхронное обновление через publish и отклонение load с Error. Если фреймворк группирует обновления, размонтирует компоненты или API отклоняет другим значением, обработайте и это.

Проверьте A → B → A тремя отдельными чтениями

Дайте каждому вызову свой отложенный промис. Ранняя фикстура связывает A с одним промисом, B с другим; повторное использование для второго A скрыло бы различие первого и третьего запросов.

Предположим, load может возвращать разные снимки одного пути, например после редактирования между чтениями. Тест явно задаёт «A version 1» и «A version 2». Это контролируемые учебные примеры, не инцидент Yenpo. Если загрузчик всегда возвращает один неизменяемый снимок, различия текста не будет; проверьте кэширование и чтение до применения объяснения.

В архиве есть намеренно неполный createPathOnlyReader. Он отслеживает selectedFile и защищает успех, ошибку и finally через file === selectedFile. После повторного выбора A все три проверки принимают и первый A:

Событие Проверка пути Проверка номера
Выбор A, B, снова A Текущий путь A Текущий запрос 3
Третий запрос возвращает A версии 2 Показывает A версии 2 Показывает A версии 2
Приходит B Игнорирует B Игнорирует запрос 2
Первый запрос возвращает A версии 1 Показывает A версии 1 Сохраняет A версии 2

Парные тесты выполняют этот порядок на обоих читателях. reproduce: ожидает неверную версию, доказывая сбой проверки пути; тест номера ожидает версию 2. Ещё два завершают первый A, пока третий ждёт: старый успех должен оставить текст пустым и загрузку активной, старый сбой — ошибку пустой. Собственная ошибка текущего A должна появиться.

Сохраняйте независимость двух читателей

Создайте читателя для каждого независимого вида с собственным замыканием latest и целью publish. Используйте его повторно для следующих выборов в том же виде; пересоздание на каждый клик теряет общий счётчик, делающий старые вызовы неактуальными.

Последний тест создаёт левого и правого читателя с отдельными отложенными загрузчиками и состояниями, затем выполняет:

  1. Выбрать A слева, A справа, затем B слева. Текущий номер слева — 2, справа — 1.
  2. Завершить правый A, пока левый B ждёт. Справа должен появиться результат и закончиться загрузка; слева — пусто и загрузка.
  3. Отклонить старый левый A. Слева должно продолжаться ожидание B без ошибки.
  4. Завершить левый B. Слева — B, справа по-прежнему свой A.

Перенос latest в область модуля ломает шаг 2: новый выбор слева аннулирует правый запрос и оставляет загрузку справа. Общее видимое состояние тоже нарушило бы изоляцию даже при разных счётчиках. Тест проверяет оба вида после чередующихся завершений.

Ошибки и загрузка тоже могут попасть не туда

Если защитить только publish({ text }), неверный текст исчезнет, но останутся две ошибки:

  • A падает после выбора B. Его catch показывает «A failed» в виде B.
  • A завершается, пока B загружается. Его finally выключает индикатор B.

Для второй ошибки завершите A, пока B ждёт, и сразу проверьте, что loading всё ещё true. Проверка лишь после B не заметит раннее выключение индикатора.

Для первой отклоните A во время ожидания B и проверьте отсутствие ошибки и активную загрузку B, затем завершите B. Добавьте тест сбоя текущего запроса: проверка, глотающая все ошибки, прошла бы старый случай, скрывая реальные сбои.

Правило строже, чем «правильный текст когда-нибудь появится»: только текущий запрос может публиковать при завершении что-либо — текст, ошибку или выключение индикатора.

Отмена и игнорирование — разные действия

Если загрузчик поддерживает отмену, отмена A экономит работу. Но A всё ещё может опубликовать результат: он уже в кэше, операция игнорирует сигнал или завершается одновременно с отменой.

Используйте отмену для ненужной работы, номер запроса — для права менять вид. Проверьте, действительно ли загрузчик соблюдает AbortSignal: не каждый API с промисами это делает.

Игнорирование старого результата не отменяет действий load по пути. Здесь загрузчик только возвращает текст. Если реальный загрузчик пишет в общий кэш или модель, проследите записи отдельно. Счётчик защищает только вид.

Запустите тесты

Скачайте async-selection.mjs и запустите с Node.js 22 или новее:

node --test async-selection.mjs

Десять тестов охватывают перезапись A → B, защиту успеха/ошибки/загрузки, повтор A → B → A и двух независимых читателей. Два reproduce: намеренно проверяют сломанные результаты; остальные восемь — нужное поведение. Без таймеров и зависимостей.

В реальном проекте найдите входной обработчик, каждый await или callback, изменяемое во время ожидания общее состояние и все записи на обратном пути. Запишите порядок рядом с исходниками.

В Yenpo держите входной файл открытым и попросите AI Reader проследить состояние через await. Сверьте ответ с путями успеха, ошибки и finally. Если он заканчивается на «промис завершается», уточните: какой запрос имеет право использовать результат?