ユーザーが A、次に B をクリックしました。タイトルは B なのにエディターには A の内容が出ます。どちらの読み取りも正しいデータを返していますが、ユーザーが移った後に届いた A の表示を止める処理がありません。

両方とも同じ click → load → render を通るので、呼び出しの列だけでは説明できません。各要求の開始と終了時点、画面を更新してよい要求を知る必要があります。小さな JavaScript 例で、見落としやすいエラーと読み込み状態も確認します。

最初の await まで読む

単純化したリーダーです。load は本文の Promise を返し、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 いいえ

同じコードでも順序により、最後に選んだファイルではなく最後に完了した読み取りが勝ちました。

修正前に UI の要件を確認します。単一プレビューなら最後の選択を表示しますが、一括インポートは全結果が必要で、保存キューは順番どおりの完了が必要かもしれません。後二者では古い結果を捨てるのは誤りです。以下は新しい選択が古い選択を置き換えるビュー用です。

タイマーなしで再現する

A を 100 ms、B を 10 ms 待たせるテストは遅く、スケジューリングに依存します。deferred Promise なら完了を正確に決められます。

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 で拒否する前提です。フレームワークのバッチ更新、コンポーネント破棄、別型の拒否などは追加で扱う必要があります。

A → B → A を別々の読み取りで試す

呼び出しごとに別の deferred Promise を使います。前の fixture は A と B に一つずつ割り当てるので、二回目の A に再利用すると最初と三番目の区別を隠します。

同じパスでも読取間に編集されるなど、load が異なるスナップショットを返す場合を考えます。テストは「A version 1」と「A version 2」を明示します。これは制御した教材例で、Yenpo の実障害ではありません。常に同じ不変スナップショットを返すローダーでは本文差は起きないため、説明を適用する前に実際のキャッシュと読み取りを確認します。

配布物の不完全な createPathOnlyReader は selectedFile を記録し、成功、エラー、finally を file === selectedFile で守ります。それでも A を選び直すと、三つとも最初の A を受け入れます。

イベント パスだけの条件 要求番号の条件
A、B、A を選択 現在のパス A 現在の番号 3
三番目が A version 2 を返す version 2 表示 version 2 表示
B が戻る B を無視 要求 2 を無視
最初が A version 1 を返す version 1 表示 version 2 を保持

対のテストで同じ順序を両版に与えます。reproduce: は誤った版を期待し、番号版は version 2 を期待します。さらに、三番目の待機中に最初の A を完了させます。古い成功なら本文は空で読み込み中、古い失敗ならエラーなしのままです。現在の A 自身の失敗は表示する必要があります。

二つのリーダーを独立させる

独立したビューごとに、専用の latest クロージャーと publish を持つリーダーを作ります。同じビューの次の選択では再利用します。クリックごとに作り直すと、古い呼び出しを無効にする共有カウンターを失います。

最後のテストは左右に別の deferred ローダーと状態を持たせ、次の順序で実行します。

  1. 左 A、右 A、左 B を選ぶ。左の現在番号は 2、右は 1。
  2. 左 B の待機中に右 A を解決。右だけ結果を表示して停止し、左は空のまま読み込み中。
  3. 左の古い A を拒否。左はエラーを出さず B を待つ。
  4. 左 B を解決。左は B、右は自分の A を保持。

latest をモジュールスコープに動かすと、左の新しい選択が右を無効にし、2 で右が読み込み中のままになります。カウンターが別でも表示状態を共有すれば独立性が壊れます。テストは交錯した完了後に両ビューを確認します。

エラーと読み込み状態も別の画面に届く

publish({ text }) だけを守ると誤本文は消えますが、二つの問題が残ります。

  • B の選択後に A が失敗し、A の catch が B に「A failed」を表示する。
  • B の待機中に A が終わり、A の finally が B の読み込み表示を消す。

後者は B がまだ待機中に A を完了させ、直後に loading が true か確認します。B の完了後しか見なければ早い消灯を見逃します。

前者は B の待機中に A を拒否し、B はエラーなしで読み込み中か確認してから B を解決します。現在の要求が失敗する別テストも必要です。すべてのエラーを捨てる実装でも、古いエラーを無視するテストだけなら成功してしまいます。

規則は「最終的に正しい本文」より厳密です。完了時に本文、エラー、読み込み停止を書き込めるのは、現在の要求だけです。

キャンセルと無視は異なる

ローダーが対応していれば A のキャンセルで処理を節約できます。ただし結果がキャッシュ済み、信号を無視する、キャンセルと同時に完了するなど、後から書き込む可能性は残ります。

不要な処理の停止にキャンセルを、表示の更新資格には要求番号を使います。Promise を返す API がすべて AbortSignal に従うわけではないので、実際の対応を確認します。

古い結果を無視しても、load が途中で行ったことは戻りません。この例は本文を返すだけですが、共有キャッシュやモデルも更新する実装なら、その書き込みも別に追います。カウンターが守るのはビューだけです。

テストを実行する

async-selection.mjs を Node.js 22 以降で実行します。

node --test async-selection.mjs

10 件で元の A → B 上書き、成功・エラー・読み込みの保護、A → B → A、二つの独立したリーダーを確認します。二つの reproduce: は壊れた結果を意図的に期待し、他の八つは必要な動作を確認します。タイマーも依存パッケージも使いません。

実際のコードでは、入口ハンドラー、各 await やコールバック、待機中に変わる共有状態、戻り道のすべての書き込みを探します。ソース位置の横に順序を書きます。

Yenpo で入口を開いたまま、await をまたぐ状態を追うよう AI Reader に依頼できます。回答を成功、エラー、finally と照合し、「Promise が解決する」で止まったら「どの要求が結果を使ってよいか」と追問してください。