JotaiのatomWithStorageを更新したとき、atomの値とlocalStorageの両方が同時に更新された、と考えていませんか。
Jotai 2.20.3の同期ストレージ経路では、メモリの値が変わったあとに保存が失敗する場合がありました。 その例外が任意の履歴保存から主処理へ伝わると、動画の読み込みに到達しないケースも再現できました。
この記事では、変更していないVideoHostのコンポーネントを使い、正常系・別キーへの障害・履歴だけの保存拒否を比較します。AI支援で実装・執筆し、ソースと実行結果を照合したローカル検証です。
メモリ・保存先・主処理を分けて観測する
公式ドキュメントによると、ストレージを指定しないatomWithStorageはlocalStorageを利用します。
検証した2.20.3の配布実装では、同期更新の最後が次の順番でした。以下は配布コードの抜粋です。
set(baseAtom, nextValue);
return storage.setItem(key, nextValue);
後者がQuotaExceededErrorを投げても、それより前のメモリ更新がなかったことになるわけではありません。実際のatomを使った検査でも、次の差を確認しました。
| 観測先 | 通常の書き込み | 対象キーだけを保存拒否 |
|---|---|---|
| atomのメモリ | 新しいレコードあり | 新しいレコードあり |
localStorageの値 |
古い+新しいレコード | 古いレコードのまま |
| 呼び出し元へ返る例外 | なし | QuotaExceededError |
そのため、画面やatomの値だけでは保存成功を判定できません。保存先は別に読み戻す必要があります。 これはこの版・同期経路での観測であり、すべてのストレージ実装やJotaiの版に一般化した結果ではありません。
任意の履歴が、最初の動画読み込みを止める
対象はVideoHostの14630a0aです。Video.tsxの新しいURLを処理するeffectでは、履歴の更新がHLSの初期化より前にあります。
この実際のコンポーネントとatomを変更せず、React・Jotai・Hlsを使う検証用画面へ組み込みました。動画はローカルで生成した12秒の青いHLS映像、履歴は合成データです。バックエンドやアカウントは使いません。
| ケース | 結果 | 確認したこと |
|---|---|---|
| 正常 | PASS | manifest・segmentの取得、再生位置の前進、履歴の保存 |
| 無関係なキーだけ保存拒否 | PASS | 拒否処理の校正後も、実際の履歴保存と再生が成功 |
WatchedHistoryだけ保存拒否 |
FAIL | コンポーネントからの書き込みが例外になり、HLS初期化・manifest取得へ未到達 |
正常系の検証用画面です。短く再生したあと10.2秒へシークしています。履歴の約0.85はシーク後の位置で、10秒間の連続視聴を測った値ではありません。
履歴だけを保存拒否した場合です。表示される例外文は検証用のError Boundaryによる観測表示で、VideoHost本番のエラー画面ではありません。
「エラーが出た」だけでテストを終えない
保存障害のテストでは、準備失敗を製品の不具合と取り違えない条件が必要です。このテストは、少なくとも次を確認してから主処理の継続を判定します。
- 正常系と、無関係なキーを拒否する対照実験が通る。
- 拒否したのは
WatchedHistoryへの実際の書き込みで、スタックがVideo.tsxを通る。 - あらかじめ保存した古い履歴のバイト列は変わっていない。
- 捕捉した例外は、注入した目印付きの
QuotaExceededErrorと一致する。
その後の期待値は「任意の履歴が保存できなくても、主処理は進む」です。公開テストから抜粋すると、次の判定です。単独で実行できるコードではなく、前述の準備と観測を伴います。
expect(state.fixture.error,
'OUTCOME: optional history must not abort initial Video effect before media loading'
).toBeNull();
await requireActualPlayback(page, { historyMustPersist: false });
現在の固定ソースでは最初の判定が失敗します。test.failやskipで成功扱いにはしていません。別の仮修正版で履歴の保存例外を主処理から分離すると3件とも通り、テストがその差を検出できることも確認しました。上流への修正反映を示すものではありません。
同じ検証を手元で動かす
検証セットのZIPを展開するとvideo-host-history-native/フォルダができます。Node 22.12以上を用意し、展開したフォルダの一つ上のディレクトリで次のコマンドを実行します。手順・制約と実測結果も公開しています。元のMITライセンスと8ファイルのソース照合を含みます。
cd video-host-history-native
npm ci --ignore-scripts --no-audit --no-fund
npx playwright install chromium
npm run controls
npm test
controlsは2件成功、npm testは2件成功・1件失敗で終了コードも非0になります。固定ソースに対する回帰テストの結果です。127.0.0.1:54230が空いている必要があります。
自分のアプリへ移すときは、対象コンポーネント、通常操作、履歴キーを一緒に置き換えます。主処理が進む証拠と、保存先の読み戻しは残します。履歴が任意である場合と、注文・編集内容など保存そのものが主目的である場合とでは、必要な失敗時の動作も変わります。
今回調べたのは、初回effectでの合成的な書き込み拒否です。実際に容量が尽きる頻度、後続の再生位置保存、本番のバックエンド、アプリ全体の動作は未検証です。また、古い履歴の読み込みは検証画面のuseAtomValueで行っており、実アプリ全体の購読構成を再現したものではありません。

