表示モード切替のフリーズは、トグルではなく同じコミットの重い更新だった
ヘッダーの表示モード切替を押すと画面全体が数秒固まる。犯人はトグルではなく、確定した isAltMode を購読していた一覧 TOP が同じコミットで 11 枠を一括アンマウントし、再生中の video を破棄していたこと。pending / deferred / debounce の 3 つの時間軸に分けて、クリックフレームから重い仕事を追い出した記録。
ヘッダーの表示モード切替(標準 / 拡張)を押すと、一部の環境で画面全体が数秒固まる。再現は 5 人中 1 人。下までスクロールした一覧 TOP で、ヒーロー枠の動画を再生しながら別のアプリを開いているとき、出やすい。
最初に疑うのはトグル自体だ。クリックハンドラが重い、状態更新が遅い、ボタンの描画が詰まっている。しかし切り分けははっきりしていた。マイページやお知らせでは出ない。一覧 TOP だけだ。
このフリーズの本体はトグルではなく、isAltMode の更新と同じコミットで走っていた一覧 TOP 全体の同期処理だった。「見た目の応答」と「カタログの重い更新」をフレーム単位で分けた理由を整理する。
この改修が解いている問題
UI 仕様はシンプルだ。標準と拡張を切り替える。中身は切り替えた側の一覧で出る。トグルの見た目はすぐ追従する。
起きていたのは、仕様どおり動いたあとの メインスレッド停止 だ。
| 観察 | 意味 |
|---|---|
| 遅延は数秒、画面全体 | トグル 1 個の再描画では説明できない |
| 一覧 TOP 限定 | ヘッダー共通処理単体が犯人ではない |
| 下スクロール + 動画再生中 | 表示済みカード群と <video> が疑わしい |
| 他アプリ負荷があると出やすい | デコーダ破棄や大量 unmount がメインスレッドを奪う |
ポイントは、トグルが遅いのではなく、トグルの確定と同じ描画でカタログ全体を捨てていたことだ。ユーザーから見ると「押した瞬間に固まる」のでトグルのバグに見える。実際には、確定した isAltMode を購読している一覧 TOP が、同じコミットで次を一斉にやっていた。
- 表示済みセクション(約 11 枠)を
isAltModeの変化で一括アンマウント - ヒーロー枠をスケルトンへ切り替え、再生中
<video>を同期破棄 - 表示済みカード群の再取得を開始
負荷の高い PC では、このまとまりがメインスレッドを数秒止める。トグル見た目の更新も、そのコミットの中にいたので一緒に遅れた。
クリックからフリーズまでのクリティカルパス
改修前の経路は、おおよそ次のとおり。
クリック
→ URL を更新(history push)
→ 切替の計測イベントを送る
→ 描画 A(URL 購読者だけ。トグルはまだ旧 isAltMode)
→ useEffect で CommonContext に dispatch
→ 描画 B(Context 全購読者 + 一覧 TOP + トグル)
→ useEffect 群
・useInViewport × 11: 表示済み枠を一括アンマウント
・ヒーロー枠: banners クリア + loading → 次描画で video を同期破棄
・表示済みカード: 再取得開始
→ 描画 C(大量 unmount + 動画デコーダ破棄)← ここで数秒止まりうる
トグルの見た目は描画 B まで変わらない。描画 B〜C が一覧 TOP 専用の重い処理なので、他画面では再現しない。
ここでの isAltMode は一覧 TOP 専用のフラグではない。検索・詳細・マイページ・お知らせ・再オープン時の復元(InMemory)に効く共通状態だ。だから「TOP だけ直せばよい」と「共通状態を雑にいじる」は両立しない。描画の分離は TOP に閉じ、値の最終結果は画面横断で揃える必要がある。
楽観的 UI だけでは足りない
「トグルが遅れて見えるなら、ボタン側で先に選べばよい」は自然な発想だ。実際、見た目の即時切替は必要だった。ただし優先度は次の順になる。
- メインスレッドを長時間止めない
- トグルを次のペイントまでに切り替える
楽観的 UI だけ入れても、同じフレームで 11 枠の unmount と動画破棄が走れば、画面は固まる。固まった画面の上でトグルだけ先に変わっても、体感は「押したら止まった」のままだ。
だから先に切るべきは、クリックと同じコミットに載っている重い仕事だ。トグルの先行表示は、その仕事を後続フレームへ逃がしたあとに意味を持つ。
3 つの時間軸に分ける
改修後、1 回のクリックは 3 つの時間で進む。
| 時間軸 | 何を動かすか | いつ確定するか | 画面への影響 |
|---|---|---|---|
| 即時 | pendingIsAltMode と overlay loading | クリックしたフレーム | トグル見た目、不透明 overlay、先頭スクロール |
| 短い待ち | URL / CommonContext.isAltMode / 切替の計測 | 300ms debounce の最後の値 | 共通状態。ヘッダー以外の購読者が追従し始める |
| 遅延 | CatalogContent の key 載せ直し | useDeferredValue(isAltMode) の後続フレーム | カタログ本体。カード・ヒーロー枠・表示計測 |
ヘッダーが見ている値と、カタログが見ている値は意図的に違う。
// ヘッダー: ユーザーが今選んだ側を先に見せる
const visualIsAltMode = pendingIsAltMode ?? isAltMode;
// 一覧 TOP: 確定した isAltMode だけを、しかも 1 フレーム以上遅らせて購読する
const deferredIsAltMode = useDeferredValue(isAltMode);ModeToggleButtons に local state は持たせない。ボタンは props の isAltMode / isDisabled だけを描画する。先行表示の正は pendingIsAltMode だ。ボタンが独自に楽観すると、ヘッダー・サブナビ・検索左カラムで見た目が割れる。
この分離が、仕様の「中身の再取得完了を待たずにトグルだけ切り替える」を素直に実装させてくれる。トグルを isAltMode そのものに結びつけてしまうと、「カタログが終わるまでボタンも動かない」か「ボタンと中身が同じコミットで一緒に重い」かの二択に引きずられる。
クリックフレームではカタログを捨てない
一覧 TOP の本体は CatalogContent だ。ヘッダーと overlay はその外に置く。
const deferredIsAltMode = useDeferredValue(isAltMode);
{deferredIsAltMode !== undefined && (
<CatalogContent
key={deferredIsAltMode ? 'alt' : 'default'}
isAltMode={deferredIsAltMode}
switchLoading={modeSwitchLoading}
/>
)}
{modeSwitchLoading && <CatalogSwitchOverlay />}クリックでは overlay と pendingIsAltMode だけを先行コミットする。そのフレームでは CatalogContent をアンマウントしない。isAltMode が確定し、useDeferredValue が追いついたあとで key が変わり、中身だけが初回マウント相当で載せ直される。
useDeferredValue は載せ直し自体を軽くしない。やることは「ヘッダーと overlay を先にペイントし、重い更新を後続フレームへ送る」だけだ。CPU 4x スロットルでは、遅延したあとに数秒固まるように見えることがある。だから deferred は必要だが、十分ではない。クリック瞬間の Long Task を消す本体は、同じフレームで 11 枠と video を捨てないことだ。
overlay は隠すためであって、中身を消すためではない
切替中は不透明 overlay で覆う。旧モードと新モードの混在を見せないためだ。ただし CatalogContent 全体を visibility: hidden や opacity: 0 にはしない。
遅延描画は useInViewport(Intersection Observer)で動いている。親を非交差扱いにすると、スクロールしても枠がマウントされない。覆う対象は不透明 overlay であり、観測対象の DOM は残す。
ヒーロー枠の動画も同じ考え方だ。スケルトンを出す契約(旧バナーを出さない)は維持する。しかし再生中の <video> をその場で unmount すると、デコーダ破棄がメインスレッドを止める。切替中は DOM を残して pause し、unmount 時の cleanup は pause + src 解除だけにする。load() は呼ばない。src 解除の直後に load() すると、それ自体がメインスレッドを止め、続けて unmount でもう一度デコーダ破棄が走る。
// unmount 後始末。デコーダ解放を明示的・短時間にする
video.pause();
video.removeAttribute('src');
// video.load() はしないoverlay 開始直後は、ヒーロー枠自身の loading がまだ false だ。見た目は overlay で隠れていても、Tab やスクリーンリーダーは旧バナーに届く。だからヒーロー枠のコンテンツだけ aria-hidden / inert にする。CatalogContent 全体は隠さない、という制約と同じ線だ。
なぜ「同じ DOM 上で 11 枠を維持する」をやめたのか
一括アンマウントの引き金は、遅延描画フック useInViewport に渡していた resetTrigger: isAltMode だ。最初の仮説は、これを外して表示済み枠を残す、だった。残すと、先頭へスクロールしたあと遅延枠が出ない問題が起きる。Observer が「すでに見た」と覚えている、root が違う、ref が遅延マウント後に取れない、といった枝に入った。
試して撤回した対策の典型は次のとおり。
| やったこと | 結果 |
|---|---|
画面外で isInViewOnce を落とさない | 切替後の空白は残った。フック全体の契約変更になる |
Observer の root を overflow 祖先にする | ヘッダーリロード後も出ない |
ref.current を deps に戻し、遅延マウント後に observe | 他ページからの遷移でも出ない |
| root を aria-label で特定し、ホストを常時マウント。フックが Main を import | アプリ全体が白画面(循環参照) |
広域のフック改修は、一覧 TOP 以外の契約まで動かす。白画面まで出した時点で、方針を切り替えた。
遅延描画の前提を、切替後も「初回マウント」に戻す。 CatalogContent だけを key で載せ直す。MainTemplate とヘッダーと overlay は残す。useInViewport は子の中で普通に初回 IO する。同じ DOM 上で 11 枠を延命する対策は捨てる。
計測もこれに揃える。切替時に表示中だった下の枠へ表示イベントを再送する、という別経路は使わない。載せ直し後の初回表示と同じ扱いにする。画面内に入った枠だけ送り、スクロールしていない枠は送らない。
先頭スクロールも同じ理由だ。overlay のスケルトンはページ先頭にある。下スクロールしたまま覆うと、不透明背景しか見えない。loading が立ったフレームで、useLayoutEffect により Main の overflow 領域を先頭へ戻す。window.scrollTo は使わない。スクロールしているのは window ではなく Main テンプレート側だからだ。
コミットは最後の 1 回、見た目は毎回
フリーズを止めたあとでも、連打は別の壊れ方をする。標準 → 拡張 → 標準を 300ms 以内に押すと、取得・動画破棄・計測・history が回数分走る。遅い応答が後から返れば、トグルと違うモードのカードが残る。
ここでも時間軸を分ける。
- 見た目: 押すたびに
pendingIsAltModeを更新する - 確定: 300ms debounce の最後の値だけ、URL
{ replace: true }+ Context + 計測
一覧 TOP では overlay 中(ヒーロー枠の取得完了まで)トグルを disable する。検索・お知らせでは debounce 窓だけ disable する。カード取得完了まではロックしない。長く押せなくなる方が、フリーズより目立つ。
const updateIsAltMode = (value: boolean) => {
dispatch({ loading: true, pendingIsAltMode: value }); // 即時
cancelPendingCommit();
sharedTimer.current = setTimeout(() => {
commitIsAltMode(value); // URL replace + 計測。最後の value だけ
}, 300);
};debounce timer は入口が複数でも 1 本にする。ヘッダーとサブナビ、検索左カラムが別 timer を持つと、300ms 以内の連続操作で URL / 計測が二重になる。
カード取得は、先に返った古い応答を捨てる。ヒーロー枠と同様に Abort するか、generation で無視する。ガード(debounce)だけ先に入れると、debounce 後の再クリックで古いリストが残るので、stale 無視とセットで入れる。詳細は 検索 Hook のレースコンディション に書いた。
debounce 確定前に別ページへ遷移したら、未確定の commit は破棄する。遷移先は InMemory の旧値だ。timer だけ残すと、アンマウント後に URL が書き換わり、トグルが固まる。
pending を CommonContext に足さない
実装の途中、pendingIsAltMode は一覧 TOP 用 Context に置いていた。ヘッダーがその Context を読むと、一覧ページ以外では default の no-op になる。お知らせや検索では、debounce と URL 同期が終わるまでトグルが旧値のままだ。即時フィードバックが TOP 限定になってしまう。
では CommonContext に modeSwitch を足せばよいか。購読者が多いので、クリック瞬間にアプリ全体が再描画する。今回の再現は一覧 TOP 限定であり、共通 Context の分割とも違う話だ。
採ったのは、pending と loading だけの小さい Context だ。
interface IModeSwitchState {
pendingIsAltMode?: boolean; // ヘッダーが先に見せる値
loading: boolean; // TOP overlay / トグル disable
}CommonContextProvider の内側にネストする。ヘッダーがあるページへ一度に届き、entrypoint を 8 ファイル触らずに済む。一覧ページ限定にもしない。
hook の切れ目も同じ考え方だ。
| hook | 責務 | 持たないもの |
|---|---|---|
useDisplayMode | URL ↔ CommonContext、確定時の計測 | debounce、overlay、CatalogContext |
useModeSwitchTransition | クリック受付、pending + loading、非 TOP の解除、debounce | TOP の overlay 解除(ヒーロー枠の取得完了のまま) |
ヘッダーは CatalogContext を見ない。visualIsAltMode = pendingIsAltMode ?? isAltMode だけで足りる。TOP の overlay 解除を汎用 hook に寄せると、検索・お知らせで loading が落ちなくなる。ヒーロー枠の再取得はもともとバナー hook の仕事だ。
isDebouncing は専用 Context に載せない。非 TOP の disable は debounce 窓、TOP は overlay、という切り分けを保つためだ。
よくある代替案と、採用しない理由
1. トグルに local の楽観 state を持たせる
ボタンはすぐ切り替わる。ただし正が 2 つになる。ヘッダーと検索左カラムとサブナビで、連打中の見た目がずれる。先行表示は pendingIsAltMode に寄せた方が、disable も overlay も同じソースになる。
2. isAltMode 確定を待ってから overlay を出す
混在は減る。しかしフリーズは最初の paint から始まる。確定を待つと、重い更新と同じフレームで overlay が出る。ユーザーには「押してから固まり、固まったあとで覆いが来る」と見える。overlay はクリックフレームで先に出す。
3. CommonContext を分割する
購読者は確かに多い。ただし再現条件(TOP 限定、下スクロール、動画再生中)と一致しない。分割は横断影響が大きく、本改修の主因を外す。切替の途中状態だけ小さい Context に出す方が、クリック瞬間の再描画範囲と合う。
4. useInViewport を直して、同じ DOM 上で枠を生かす
遅延描画の契約変更になり、他画面とヘッダーリロードまで巻き込む。循環参照で白画面まで出したので、CatalogContent の key 載せ直しに切り替えた。載せ直しは粗いが、初回マウントと同じ前提に戻せるので、計測もスクロール遅延も説明が短くなる。
5. debounce せず、クリックごとに Context を更新する
即時性は上がる。その代わり取得・動画破棄・計測・history が嵐になる。見た目は即時、確定だけ 300ms に寄せる方が、トグルの体感とメインスレッドの両方を守れる。
判断のチェックリスト
「この state を今更新してよいか」を見るときは、次の順が使いやすい。
- その更新は、次のペイントに間に合わせたいユーザー応答か?
Yes → 小さい state(
pending/ overlay)だけを、クリックフレームでコミットする - その更新は、カタログや media など破棄コストの高い UI か? Yes → 同じフレームでは触らない。deferred / 次フレーム / 覆いの下でやる
- その値は画面横断の正(URL / 永続化 / 計測)か?
Yes → debounce の最後の 1 回にまとめる。history は
replace - 途中状態を既存の大きい Context に足すと、購読者が一斉に再描画するか? Yes → 専用の小さい Context に閉じる
- 遅延描画や Observer の途中状態を、特例で延命しようとしているか? Yes → 先に「初回マウント相当へ戻す」を検討する。フック広域改修は最後
今回のフリーズは、1 と 2 を同じコミットに置いていたことが原因だった。3 と 4 は、連打と画面横断で後から効いてきた。
改修後の時系列
クリック
→ トグル見た目: 即 新モード(pendingIsAltMode)
→ 一覧 TOP: overlay と disable。同じフレームで Main を先頭へ
→ 検索等: debounce 中だけ disable
→ 300ms 後、最後の選択だけ URL replace + Context + 計測
→ CatalogContent: overlay の下で deferred 追従 → key 載せ直し
→ ヒーロー枠: overlay 中は pause + hidden/inert。載せ直し後に新モードを取得
→ カード: 初回マウントと同じ遅延描画。下へスクロールすると出る
→ 表示計測: 載せ直し後の初回 IO。画面内だけ
→ overlay 解除後は、カード取得中でも再クリック可
機能の意味(モードの切替、永続化、ディープリンク)は変えていない。変えたのは、どの値をどのフレームで画面に同期するかだけだ。
まとめ
- フリーズの本体は トグルの確定と、一覧 TOP の大量 unmount / 動画破棄を同じコミットで同期していたこと。トグル自体は速かった
useDeferredValueは重い仕事を軽くしない。後続フレームへ動かすだけ。クリック瞬間の Long Task を消すのは「同じフレームで捨てない」設計の方- overlay は「隠す」ためのもの。
visibility: hiddenで覆うと Intersection Observer まで殺して遅延描画が死ぬ - 途中状態(
pending/loading)は大きい共通 Context ではなく、小さい専用 Context に閉じる - debounce と stale 無視はセット。片方だけでは連打時にトグルと中身がずれる
React の state は「値が変わったら UI を同期する」ためのものだ。isAltMode は共通状態として正しく、カタログもその値に同期すべきだ。ただし同期の瞬間を、クリックの瞬間と同じにしてはいけない。
ユーザーが同期してほしい相手は、まずトグルだ。カタログとデコーダは、覆いの下で後続フレームに渡せる。即時の箱(pending / overlay)、遅延の箱(useDeferredValue + key)、確定の箱(debounce 後の URL / Context)を分けたのは、その同期先の違いだった。
補足: useDeferredValue
useDeferredValue は、緊急でない値の更新を、緊急な更新のあとへ送るためのフックだ。
const deferredIsAltMode = useDeferredValue(isAltMode);isAltMode が変わると、React はまず緊急な更新(ヘッダーや overlay)を描画し、deferredIsAltMode はしばらく旧値のままになる。余裕のあるフレームで新しい値に追いつく。startTransition と親戚で、入力中の一覧フィルタなど「入力は今、結果はあとでよい」が典型だ。
今回の使い方はフィルタではない。ヘッダーを先にペイントし、CatalogContent の key 載せ直しを後続フレームへ送るためだ。
誤解しやすい点がある。
| 期待しがちなこと | 実際 |
|---|---|
| deferred すれば Long Task が短くなる | ならない。重い仕事が後のフレームに移るだけ |
| deferred 中は何も描かなくてよい | ヘッダーと overlay は今描く。カタログだけ遅らせる |
| 旧カタログが一瞬残るのはバグ | 1 フレーム程度は設計どおり。混在は overlay で隠す |
CPU スロットル下では「遅れて固まる」ように見える。それは deferred の失敗ではなく、載せ直しコストが残っている証拠だ。クリック瞬間の停止と、後続フレームの停止は別問題として扱う。前者を消すのが overlay 先行と video のソフト破棄、後者を許容範囲に収めるのが key 載せ直しの範囲(CatalogContent だけ、ヘッダーは残す)だ。
補足: stale な応答
stale(古い)応答は、先に出した要求の結果が、あとから返って現在の画面を上書きすることだ。
クリック: 標準 → 拡張 → 標準(最後は標準)
要求: 標準用 拡張用 標準用
応答: 拡張用が遅れて到着 → 画面が拡張のまま残る
debounce は「確定する値を 1 つにする」だけで、すでに飛んだ HTTP は止めない。だから取得フック側で、次のいずれかが要る。
AbortControllerで前の要求を破棄する- generation(何回目の切替か)を覚え、古い番号の
setStateを無視する
どちらも「今の isAltMode 以外の結果は描かない」ための手段だ。ヒーロー枠と告知バナーはもともと Abort を持っていた。カードとカテゴリ一覧には、同じ思想を足した。
debounce と stale 無視はセットだ。確定回数を減らしても、確定のたびに前の応答が残っていれば、トグルとリストがずれる。逆に stale 無視だけ先に出しても、連打のたびに破棄と再取得が嵐になり、フリーズが再発する。
補足: Intersection Observer と「隠す」
Intersection Observer は、要素がルートの可視領域と交差したかをブラウザが通知する API だ。遅延描画はこれを使って「画面に入ったらマウントする」をやる。
交差した → isInViewOnce = true → カードをマウント
切替で resetTrigger → isInViewOnce = false → 表示済みもアンマウント
親を visibility: hidden や opacity: 0 にすると、交差判定が「見えていない」になる。overlay で目を隠したつもりが、Observer まで止めてしまう。切替後に下スクロールしても枠が出ない、という症状はここから来る。
だから覆い方は次の分担にした。
| 対象 | 隠す手段 | 隠さない理由 |
|---|---|---|
| 一覧 TOP 全体 | 不透明 overlay(別レイヤ) | Observer の交差を維持する |
| ヒーロー枠 / 動画 | hidden / aria-hidden / inert + pause | 旧モードを操作不能にする。DOM は残す |
| 遅延枠の寿命 | CatalogContent の key 載せ直し | 初回マウントと同じ IO に戻す |
「見えない」と「マウントされていない」と「操作できない」は別だ。見た目だけ消して Observer を殺すと遅延描画が死に、DOM ごと消すと video 破棄でフリーズし、残したまま操作可能だと旧バナーに Tab が届く。3 つを同じ hidden で済ませないのが、この改修の実体だった。