決済ページを開いて、ブラウザの「戻る」を押します。戻った先のボタンは「処理中…」のまま灰色になり、何度押しても反応しません。
個人開発のメンバーシップページで、実際に出会った症状です。再読み込みすれば直るので、見過ごされやすいところが厄介でした。購入に迷って一度戻った人は、そのまま離れてしまうかもしれません。
しかも、API の呼び出しは失敗していません。ログを見ても、手がかりが残らない症状です。
起きていたのは「ページが凍結されて、そのまま戻ってきた」ことでした
ブラウザは、遷移して離れたページを bfcache(Back-Forward Cache)という場所に、メモリごと凍結して保管することがあります。戻るときは、読み込み直さずに凍結を解きます。
React のコンポーネントは再マウントされません。useState の値も、loading が true のまま残ります。ボタンに disabled={loading !== null} と書いてあれば、そのまま押せなくなります。
外部サービスへ遷移するボタンは、「戻ってきた自分」を想定して書かないと、状態だけが過去に取り残されます。
見分ける手がかりは、pageshow イベントの persisted です。bfcache から復元されたときだけ true になります。
まず、実ブラウザで再現しました
手元の感覚だけでは、直った確認が弱くなります。そこで、React を使わず、loading 変数と文言だけで同じ構造を作り、Playwright で「押す → 外部へ遷移 → 戻る」を自動で通しました。
// bfcache-check.js — 決済ボタンが「戻る」で固まらないかを実ブラウザで確かめる
// 使い方: node bfcache-check.js (playwright が入っていること)
const http = require ( 'http' );
const { chromium } = require ( 'playwright' );
// 検証用のページ。React の代わりに、loading 変数と文言だけで同じ構造を再現する
const page_html = ( guard , payUrl ) => `<!doctype html><meta charset="utf-8"><title>plans</title>
<button id="join">Join</button><p id="state">idle</p><p id="evt">none</p>
<script>
let loading = false;
const $ = (id) => document.getElementById(id);
$('join').onclick = async () => {
loading = true; $('state').textContent = 'loading';
await new Promise((r) => setTimeout(r, 50)); // /api/checkout の待ち時間の代わり
location.href = '${ payUrl }'; // Stripe へ遷移
};
addEventListener('pageshow', (e) => {
$('evt').textContent = 'persisted=' + e.persisted;
${ guard ? "if (e.persisted) { loading = false; $('state').textContent = 'idle'; }" : ''}
});
</script>` ;
const serve = ( port , handler ) =>
new Promise (( ok ) => { const s = http. createServer (handler). listen (port, () => ok (s)); });
const CASES = [
{ name: 'A 対策なし' , guard: false , noStore: false },
{ name: 'B pageshow で解除' , guard: true , noStore: false },
{ name: 'C 対策なし + no-store' , guard: false , noStore: true },
];
( async () => {
let failed = 0 ;
for ( const c of CASES ) {
const pay = await serve ( 4102 , ( _ , r ) => { r. writeHead ( 200 , { 'content-type' : 'text/html' }); r. end ( '<title>pay</title>pay' ); });
const html = page_html (c.guard, 'http://localhost:4102/pay' );
const site = await serve ( 4101 , ( _ , r ) => {
const h = { 'content-type' : 'text/html' };
if (c.noStore) h[ 'cache-control' ] = 'no-store' ;
r. writeHead ( 200 , h); r. end (html);
});
// Playwright は既定で bfcache を切っているので、その引数を外して起動する
const browser = await chromium. launch ({ channel: 'chromium' , ignoreDefaultArgs: [ '--disable-back-forward-cache' ] });
const page = await browser. newPage ();
await page. goto ( 'http://localhost:4101/' );
await page. click ( '#join' );
await page. waitForURL ( / \/ pay/ );
// page.goBack() は bfcache 復元時に load を待って固まるため、history.back() を使う
await page. evaluate (() => history. back ());
await page. waitForTimeout ( 1200 );
const state = await page. textContent ( '#state' );
const evt = await page. textContent ( '#evt' );
const ok = state === 'idle' ;
if ( ! ok) failed ++ ;
console. log ( `${ ok ? 'PASS' : 'FAIL'} ${ c . name . padEnd ( 22 ) } state=${ state . padEnd ( 8 ) } ${ evt } (Chromium ${ browser . version () })` );
await browser. close (); site. close (); pay. close ();
}
process. exit (failed ? 1 : 0 );
})();
Playwright 1.63.0・Chromium 153 で実行した結果です。
FAIL A 対策なし state=loading persisted=true (Chromium 153.0.8010.12)
PASS B pageshow で解除 state=idle persisted=true (Chromium 153.0.8010.12)
PASS C 対策なし + no-store state=idle persisted=false (Chromium 153.0.8010.12)
A が本番で起きていた症状です。戻った直後に persisted=true なのに、文言は loading のままでした。
予想と違ったのは、テストの「素通り」と、C の PASS でした
実行して気づいたことが、2つありました。
1 つ目は、Playwright が起動時に --disable-back-forward-cache を付けていることです。ここを外さないと、A のケースでも persisted=false になり、どのテストも PASS します。「戻る」の自動テストは、既定のままだと bfcache を一度も通らないまま緑になります。 本番で固まる症状が、テストでは永遠に再現しないわけです。
2 つ目は、page.goBack() です。bfcache から復元されると load イベントが発生しないため、待ち続けて 30 秒でタイムアウトしました。page.evaluate(() => history.back()) に替えて、固定の待ち時間(1.2 秒)を置いています。
そして C です。Cache-Control: no-store を付けただけで症状が消えました。直ったように見えます。ただ、persisted=false を見ると分かるとおり、原因が消えたのではなく、ページが bfcache に入らなくなっただけ でした。
直し方は 3 つ。私は pageshow を選びました
候補を並べて、何を失うかで比べました。
no-store は、決済ボタンのためにページ全体の「戻る」を遅くします。記事ページもあるサイトでは、割に合いません。reload も、結局 pageshow を書くので手間が減りません。
そこで、決済ボタンを持つコンポーネントに、pageshow で状態を戻す効果だけを足す方針に決めました。実際のコードは、こうなっています。
// 外部サービスから「戻る」で復元されたときだけ、処理中の状態を戻す
useEffect (() => {
const handlePageShow = ( e : PageTransitionEvent ) => {
if (e.persisted) {
setLoading ( null );
setError ( "" );
}
};
window. addEventListener ( "pageshow" , handlePageShow);
return () => window. removeEventListener ( "pageshow" , handlePageShow);
}, []);
e.persisted で絞っているのは、通常の読み込みでは何もしないためです。error も一緒に消しておくと、「前回の失敗表示が残ったまま」を防げます。
直したつもりのコードが、リポジトリに残っていました
メンバーシップページと、サポートページのボタンには、この対策を入れていました。ただ、直したあとに「同じ形のボタンが他にないか」を確かめた記録は、私にはありませんでした。
目で grep の結果を数えるのは不安だったので、判定の条件をスクリプトにしました。
#!/usr/bin/env python3
"""外部へ遷移するのに、pageshow で状態を戻していないファイルを洗い出す。
使い方: python3 audit_bf.py src"""
import re, sys, pathlib
NAV = re.compile( r ' ( window \. ) ? location \. ( href \s * = | assign \( ) ' )
LOAD = re.compile( r 'const \[ (\w * [ lL ] oading \w *| \w * [ pP ] rocessing \w *| \w * [ bB ] usy \w * ) , \s * set \w + \] ' )
SHOW = re.compile( r 'addEventListener \( \s * [ " \' ] pageshow [ " \' ] ' )
hits = []
for p in sorted (pathlib.Path(sys.argv[ 1 ]).rglob( '*.tsx' )):
t = p.read_text( encoding = 'utf-8' , errors = 'ignore' )
if not NAV .search(t):
continue
has_load = bool ( LOAD .search(t)); has_show = bool ( SHOW .search(t))
status = 'OK' if has_show else ( '要対策' if has_load else '状態なし' )
hits.append((status, str (p), has_load, has_show))
for s, p, l, sh in hits:
print ( f ' { s :6 } { p } (loading state= { l } , pageshow= { sh } )' )
print ( f '-- 外部遷移を持つファイル { len (hits) } 件 / 要対策 { sum ( 1 for h in hits if h[ 0 ] == "要対策" ) } 件' )
sys.exit( 1 if any (h[ 0 ] == '要対策' for h in hits) else 0 )
判定は 3 つの条件の掛け合わせです。外部へ遷移しています。処理中の状態を持っています。そして pageshow の登録がありません。この 3 つが揃ったファイルを「要対策」にしています。
自分のリポジトリで実行すると、次の出力でした。
OK src/app/[locale]/support/SupportClient.tsx (loading state=True, pageshow=True)
OK src/components/ui/MembershipPlans.tsx (loading state=True, pageshow=True)
要対策 src/components/ui/PremiumPaywall.tsx (loading state=True, pageshow=False)
状態なし src/components/ui/SearchModal.tsx (loading state=False, pageshow=False)
要対策 src/components/ui/SingleArticleCTA.tsx (loading state=True, pageshow=False)
要対策 src/components/ui/TipCTA.tsx (loading state=True, pageshow=False)
-- 外部遷移を持つファイル 6 件 / 要対策 3 件
外部遷移を持つ 6 ファイルのうち、3 つが未対策でした。しかも、どれも /api/checkout を呼んで決済ページへ送る、購入やチップのボタンです。メンバーシップページだけを直して、「決済ボタンは直した」と思い込んでいたのでした。
見つけた 3 件は、同じ pageshow の効果を足して、手順どおり再テストで確かめる予定です。症状の報告が来る前に気づけたことが、いちばんの収穫でした。
この監査にも、見逃しがあります
スクリプトを信じ込みすぎないよう、限界を書いておきます。
正規表現なので、遷移を別のフックや関数に包んでいると拾えません。OK は「pageshow という文字列がある」ことしか示しません
pageshow を書いてあっても、setLoading の呼び先が別の状態だと直っていません。最後は実ブラウザの再現が確かな手段です
検証は Chromium だけです。Safari は bfcache に積極的だと聞いていますが、私自身は手元で確かめていません。Safari で再現する端末があれば、同じ手順で試してください
手元で試す手順と、ハマりどころ
自分のプロジェクトに当てはめるときは、次の順で進めるのが確実だと私は考えています。
1. 再現スクリプトを先に動かす
node bfcache-check.js で A が FAIL になることを、最初に確認してください。ここで A が PASS になるなら、--disable-back-forward-cache が外れていないか、Playwright と Chromium の組み合わせを疑います。再現できない状態で直しても、直ったのかどうか分かりません。
2. 監査をかけて、直す対象を数える
python3 audit_bf.py src の「要対策」が、直す対象の一覧になります。本番に出ている決済ボタンだけでなく、購入・チップ・ログインなど、外部へ送り出す入口をすべて数えます。
3. 同じテストで PASS を確かめる
直したあとは、検証用ページではなく、自分のコンポーネントを /api/checkout のスタブ付きで開き、同じ操作を通します。固定の待ち時間(1.2 秒)が不安定なら、#state が idle になるまでポーリングする形に替えてください。私は、まず固定待ちで始め、揺れたら替える順で進めています。
ハマりどころは 3 つありました。
page.goBack() は復元時に待ち続けます。history.back() を使います
既定の起動オプションのままだと、本番の症状はテストで再現しません
error の表示も state に残るため、loading だけ戻しても「前回の失敗」が画面に残ります。pageshow では両方を戻します
Claude Code に決済ボタンを書かせるときの一行
新しく外部遷移のボタンを増やすたびに、この確認を思い出せる自信がありませんでした。そこで、CLAUDE.md に次の二行を足す予定です。
## 外部サービスへ遷移するボタン
- Stripe・OAuth など別ページへ遷移するボタンは、 `loading` 系の state を持たせ、
同じコンポーネントで `pageshow` ( `e.persisted` のときだけ)で state を戻すこと。
- 追加・変更したら `python3 audit_bf.py src` が exit 0 になるまで直すこと。
これで、新しいボタンを頼んだときも、監査が通るまでが 1 つの作業になります。許可ルールの棚卸しと同じく、直したあとの再発防止まで手順に残しておく考え方です。詳しくは 許可ルールの棚卸しの記事 も参考になるかと思います。決済まわりの実装は、Stripe サブスクリプションの本番構成 と Webhook のローカル検証 にも書いています。