CSVを2つ選び、キーと列の対応を決めて差分を見る。比較が終わったあとで片方のCSVを選び直したら、さっきの結果はいつ消えるべきでしょうか。
新しい比較が終わるまで残すと、画面のファイル名は新しく、差分は古いという状態になります。その間に保存ボタンを押せば、別の入力から作った結果を持ち出せます。
ブラウザー内でCSVを比較する非公開試作では、ファイルや比較設定の変更と同時に旧結果を無効にし、遅い応答で復活しないようにしました。この記事は、その実装と保存済みの試験記録から結果の寿命を整理したものです。
この試作はプレビュー、比較、HTMLレポート生成をWorkerで行い、画面側のcontrollerが結果の状態を管理します。
結果が失効する操作を先に決める
次のA・Bは、起こり得る順序を説明するための例です。顧客環境で起きた事故の記録ではありません。
- 設定Aで比較を始める。
- 終了を待たず、キー列を設定Bへ変える。
- Aの比較結果が返る。
3の結果は、計算自体が正しくても現在の画面には使えません。結果の内容を調べて判断するより、何を変更したら結果が失効するかを状態遷移に書いておく方が扱いやすくなります。
この試作の規則は次のとおりです。
| 操作 | 完了済みの比較 | 未保存レポート |
|---|---|---|
| CSV・文字コード・キー・列対応・保護列を変更 | 破棄 | 破棄 |
| 案件名だけ変更 | 保持 | 破棄して新しい名前で再生成 |
| 比較をやり直す | 破棄 | 破棄 |
| 停止・reset | 破棄 | 破棄 |
案件名は差分計算に使わず、レポートの見出しに使います。そのため、名前を変えただけで完了済みの差分まで消す必要はありません。停止は選択したCSVを保ち、resetはファイル参照も外します。
失効時には、結果表示とレポート用データを状態から消します。保存ボタンも同じ状態から有効・無効を決めます。表示だけ消して、別の場所に古い保存データを残すと、この規則が崩れます。
入力世代と処理IDは、別のものを数える
入力変更ごとにepochという世代番号を進め、開始する処理にその値を渡します。応答を受け取るreducerは、現在の世代と違う応答を無視します。
このepochは入力と状態の世代です。案件名の変更、停止、resetでも進めます。入力を変えずに比較をやり直す操作では進めません。
// 状態更新部分の抜粋。eventには開始時のepochを持たせる。
if (event.epoch !== state.epoch) return state;
これで「設定Aの結果を設定Bの画面へ戻す」ことを拒否できます。しかし、入力を変えずに比較をやり直した場合、前後の処理は同じepochです。
そこで、開始する処理ごとに別のtokenを割り当てます。左右のプレビュー、比較、レポートはそれぞれ処理スロットを持ち、そのスロットが現在受け付けているtokenだけを通します。
tokenはWorkerから返るeventには含めず、開始時のcallbackに捕捉します。現在のepochを含むinputを用意し、比較処理を開始してスロットへ登録する部分は次のとおりです。開始側と応答側の抜粋であり、入力検証や旧処理の取消し、Workerの生成は省いています。
const token = nextToken++;
const handle = runJob(input, event => onCompareEvent(token, event));
handles.compare = { token, handle };
runJobのcallbackは、スロット登録後に非同期で呼ばれる前提です。この実装はWorkerの応答でcallbackを呼び、Worker生成の失敗もqueueMicrotaskへ回しています。メッセージ通信の仕組みは MDNのWorker.postMessage説明を参照できます。
比較応答を受ける側では、callbackが捕捉したtokenを現在のスロットと照合します。
function onCompareEvent(token: number, event: JobEvent): void {
if (disposed) return;
if (handles.compare?.token !== token) return;
handles.compare = null;
setState(reduceState(state, { type: "event", event }));
}
tokenの照合を通ったあとも、reducerはepochを照合します。epochはどの入力世代に属するか、tokenは今どの処理を受け付けるかを表します。
レポートも同じ規則に従います。比較をやり直す操作は、進行中のレポート処理を取り消します。旧レポートの応答が来ても、保存を開始するcallbackまで進ませません。
停止処理と、応答を受け取る規則を分ける
実装は取消し時にWorkerを停止します。Worker.terminateの仕様では、Workerはただちに終了し、実行中の処理を完了する機会を持ちません。
それとは別に、controllerには旧応答を受け取らない規則を置いています。この規則は、Workerの停止を模擬しない単体試験でも検査できます。ここから「実ブラウザーではterminate後もmessageが届く」とは主張していません。
待ち時間に頼らず、旧応答を発火させる
単体試験では、取消しの記録を持ちつつ、その後も任意に応答を発火できる偽のjobを使います。実際のWorkerの挙動を再現するためではなく、controllerへ厳しい順序の入力を与えるためです。
保存済みの試験には、次の順序があります。
| 与える順序 | 確認する結果 |
|---|---|
| 比較A開始 → 設定Bへ変更 → 旧A応答 | 比較結果もレポートも復活しない |
| preview開始 → reset → 旧preview応答 | 初期状態に旧previewが入らない |
| レポート開始 → 停止 → 旧レポート応答 | 比較結果・生成済みレポートを残さない |
| レポート開始 → 入力を変えず再比較 → 旧レポート応答 | 同じepochでも保存callbackを呼ばない |
停止のケースは結果状態を確認する試験で、保存callbackの呼出しを直接監視していません。最後の順序は、世代番号だけでは表せない処理所有権の確認です。旧レポートの応答を受けた後、保存callbackの呼出しを記録する配列が空であることを調べています。
ブラウザー側の保存済み試験では、ファイル・文字コード・キー・列対応・保護列・resetによって旧結果と保存ボタンが消えることを確認しています。案件名だけ変更した場合は比較を保ち、レポートに新しい名前が入ることも確認しています。
これらは限られた状態遷移の確認です。あらゆるタイミングの競合や端末への保存完了を保証するものではありません。今回の執筆ではソースと既存の試験ログを照合し、試験を新しく実行してはいません。
入力を変更するたびに、消すべき表示、失効するデータ、止める処理、拒否する応答を同じ操作で扱う。その境界を決めると、比較結果を現在の入力に結び付けたままにできます。
開発・検証および本稿の整理・執筆にはAIを使用しています。実装と検証記録を根拠にしていますが、公開製品の利用実績や顧客事例は含めていません。