localStorage に書き込んだ値が反映されない 2 つの原因と対処
ブラウザに値を残しておける localStorage へ直接書き込んでからページを開き、特定の状態の画面を確かめる、という手順があります。E2E テストやスクリーンショットの自動取得でよく使う形です。
この手順で、書き込んだはずの値が画面に出てこないことがあります。原因は 2 つあり、どちらも、アプリが動き出す前に書いたか後に書いたかで決まります。
下書きを自動保存するエディタ、入力途中を保存するフォーム、表示設定を覚えている画面など、状態を localStorage へ書き出すアプリなら同じことが起きます。
症状: localStorage には値が入っているのに、画面は初期状態のまま
題材は、ブラウザで動かしている放置ゲームです。アップグレードを進めた状態の画面を撮ろうとして、次の手順を踏みました。
- ゲームのページを開く
localStorageにアップグレードのレベルを書き込む- ページを再読込して画面を撮る
保存されるオブジェクトは次の形で、アップグレードのレベルは levels の中にあります。ここでは、灯りのレベル lights を 12 にした状態を書き込みます。
{"v":1,"coins":5000,"totalPaws":120,"totalDogs":12,"levels":{"treat":20,"puppies":9,"lights":12,"breeds":4},"savedAt":1762000000000}
撮れたのは、装飾が何も出ていない初期状態の画面でした。書き込み自体はエラーも出さずに終わっています。
同じ構造の小さなページを用意して、Puppeteer で各段階の値を取り出すと、何が起きているかが見えます。書き込んだ直後の時点で、すでに画面と localStorage が食い違っています。
書き込んだ直後(再読込も待ちもしない)
画面に出ている値: lights=0
localStorage の値: lights=12
localStorage には入っていて、画面にだけ出てこない状態です。ここから原因が 2 つに分かれます。
原因 1: 読み込みはアプリの起動時に 1 回だけ
アプリ側で localStorage を読むのは、起動時の 1 行だけです。
const save = normalizeSave(loadJson(SAVE_KEY), now());
読み込んだ後は、メモリの上に持っている save を見て動きます。localStorage を後から書き換えても、アプリはそれを見に行かないです。書き込んだ直後に画面が変わらないのは、ここまでは正常な動きです。
この原因だけなら、再読込すれば反映されます。しかし、再読込しても初期状態のままでした。
原因 2: アプリが動いている間は、自動保存が書き戻す
保存側のコードは次のようになっています。
// ---- 保存 ----
function persist(): void {
save.savedAt = now();
saveJson(SAVE_KEY, save);
}
window.addEventListener('pagehide', persist);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') persist();
});
setInterval(persist, 5000);
persist は、メモリの上の save を localStorage へ丸ごと書き出します。外から localStorage を書き換えても、この save は変わらないです。そのため persist が走った時点で、外から書き込んだ値は、アプリが持っている古い状態で置き換わります。
走る契機は 3 つあります。
| 契機 | 走るタイミング | 書き込んだ値が消える条件 |
|---|---|---|
pagehide | ページから離れる時。再読込・画面遷移・タブを閉じる操作が該当する | 再読込した時点で消える |
visibilitychange が hidden | タブが裏に回った時、ウィンドウが隠れた時 | タブを切り替えた時点で消える |
setInterval による定期保存 | 5 秒ごと | 何もしなくても最長 5 秒で消える |
再読込は 1 つ目に当たります。ページから離れる時に Window: pagehide イベント が発生して古い状態が書き出され、次の起動時にはそれが読み込まれます。
開いたまま書き込み → 再読込
画面に出た値: lights=0
localStorage の値: lights=0
再読込しなくても消えます。書き込んだ後、何もせずに 6 秒待つだけで、定期保存が古い状態を書き出します。
開いたまま書き込み → 再読込しない
書き込んだ直後: lights=12
6 秒後: lights=0
画面を確かめるためにタブを切り替えた時も、同じことが起きます。

左右の違いは、書き込む位置だけです。書き込む内容も、読み込みの回数も同じです。
対処: アプリのスクリプトより前に書き込む
ブラウザを操作するツールには、ページ自身のスクリプトより前に処理を走らせる仕組みがあります。そこで localStorage に書き込んでおけば、アプリは起動時の読み込みで、注入した値をそのまま拾います。
| ツール | 使う API |
|---|---|
| Puppeteer | page.evaluateOnNewDocument |
| Playwright | browserContext.addInitScript |
Puppeteer で書くと次の形になります。ページを開く前に登録しておくところが要点です。
const state = {
v: 1,
coins: 5000,
totalPaws: 120,
totalDogs: 12,
levels: { treat: 20, puppies: 9, lights: 12, breeds: 4 },
savedAt: Date.now(),
};
await page.evaluateOnNewDocument(
(k, s) => localStorage.setItem(k, JSON.stringify(s)),
'app.save.v1',
state,
);
await page.goto('http://localhost:5199/');
この順序なら、画面にも localStorage にも、注入した値が入ります。
evaluateOnNewDocument で先に書き込み
画面に出た値: lights=12
localStorage の値: lights=12
登録した処理は、新しく開く文書ごとに毎回走ります。ページを開いた後に登録しても、その時点で開いている文書には適用されないので、登録は移動より先に置きます。
Playwright には、localStorage の内容をファイルに残して新しいコンテキストへ引き渡す storageState もあります。ログイン済みの状態を使い回す時はこちらが向いています。sessionStorage は含まれないので、必要なら初期化スクリプトで別に入れます。
ブラウザを自分で起動していない時
すでに動いているブラウザに後から接続する場合や、開発者ツールから手で確かめる場合は、初期化スクリプトを登録できないことがあります。その時は、同一オリジンの、アプリのコードが読み込まれない URL へ先に移動してから書き込みます。
await page.goto('http://localhost:5199/favicon.svg');
await page.evaluate((k, s) => localStorage.setItem(k, JSON.stringify(s)), 'app.save.v1', state);
await page.goto('http://localhost:5199/');
localStorage はオリジン単位で分かれているので、同じオリジンの別の URL で書き込んでも、アプリから見える場所に入ります(Window: localStorage プロパティ)。
別 URL で書き込み → アプリを開く
画面に出た値: lights=12
localStorage の値: lights=12
移動先は、アプリのコードが動かない同一オリジンの URL であれば何でもよいです。画像ファイル、robots.txt、存在しないパスの 404 ページなどが候補になります。
再発を防ぐ
注入した値が生きているかを、画面を見る前に確かめる
画面を見て判断すると、値が入らなかったのか、値は入っていて描画がおかしいのかを区別できないです。アプリのページを開いた直後に localStorage を読み戻して、注入した値と一致しているかを確かめると、どちらなのかがすぐ分かります。
const loaded = await page.evaluate((k) => localStorage.getItem(k), 'app.save.v1');
if (loaded !== JSON.stringify(state)) {
throw new Error('注入した状態が上書きされています');
}
読み戻しは、アプリを開いた直後に行います。定期保存が走るまでの時間を過ぎると、正しい順序で注入していても、アプリが自分の状態を書き出したあとの値が返ってくるためです。
自動保存があるかどうかを、先に確かめる
自動保存は画面に出てこないので、アプリを触っているだけでは気づけないです。localStorage へ書き出す処理が、どこから呼ばれているかを見ると分かります。
| 探すもの | 見つかった時に起きること |
|---|---|
| ページ離脱やタブの表示状態に紐づいたリスナー | 再読込やタブ切り替えのたびに上書きされる |
| 一定時間ごとに呼ばれる保存処理 | 何もしなくても、その間隔で上書きされる |
| 状態の変化に合わせて保存する仕組み | 画面に触れた時点で上書きされる |
どれか 1 つでもあれば、アプリが動いている間に書き込む手順は使えないです。
画面が期待と違う時は、確認手順の副作用も疑う
今回は、最初にゲーム側の不具合を疑いました。書き込みも読み込みも成功していて、値だけが期待と違ったので、どちらの処理も読み直しています。実際には、確認のために踏んだ手順が、確認したい状態を壊していました。
アプリの外から状態を用意する手順は、アプリの処理と同じ localStorage を触ります。画面が期待と違う時は、アプリのコードと同じ重みで、手順そのものを疑う価値があります。
まとめ
localStorage へ外から値を入れる時は、書き込む位置をアプリの起動より前に置くと、2 つの原因をまとめて避けられます。
- 外から状態を用意する時は、ページ自身のスクリプトより前に走る仕組みを使って書き込む。ブラウザを操作するツールには、そのための機能がある
- 書いた直後に画面が変わらないのは正常な動きで、読み込みが起動時の 1 回だけだからである。値そのものが消えるのは別の原因で、アプリ自身の自動保存が書き戻している
- 上書きが起きるのはページを離れる時だけではなく、タブが裏に回った時や、一定時間ごとの保存でも起きる