この記事で扱うこと
アプリで二つの大きなことをしながら学んだことを整理する。特定の製品・数値は省き、一般概念(Next App Router のレンダリング、react-query キャッシュ、HTTP 条件付きリクエスト)だけを扱う。両方の作業を貫くテーマは 「静的キャッシュ(ISR)を守りながら望む UX を得る」 だった。
今日扱ったこと
| 作業 | 何をしたかったか | やったこと | 結果 |
|---|---|---|---|
| ブートスプラッシュ | 初画面からロゴを出しつつボットには隠す | SSR・UA ゲートの代わりにインラインブロッキングスクリプト注入 | 静的レンダー/ISR 維持 + ボット隠蔽 |
| オフラインファースト | 読み取り即時性 + トラフィック最小 + 自分の書き込みの即時反映 | persist・楽観的書き込み・invalidate・durable キュー・ETag の組み合わせ | 三要求を同時に充足 |
1. 初回ペイント前の表示 + ボット隠蔽 = インラインスクリプト
どんな状況だったか。 アプリの初回進入でネイティブアプリのようにロゴスプラッシュを初回ペイントから出したかった。しかしクローラー索引にはそのスプラッシュが入らないようにする必要があった。
核心概念。 三つの代替案がそれぞれ塞がれた。SSR マークアップ は初回 HTML に載ってボット索引に漏れる。UA ゲート(リクエストヘッダーでボットならスキップ)は共有レイアウトで headers() を読んだ瞬間、下位ルートの静的生成/ISR を強制的に dynamic に変え静的キャッシュをまるごと無力化する。ISR(Incremental Static Regeneration)はページをあらかじめ静的に焼いておいて定期的にのみ更新する方式だが、dynamic になると毎リクエストのサーバーレンダーに戻って遅くなる。mount 以降にのみ現れるクライアントスプラッシュ はサーバーで初期状態が空で初回ペイントのギャップが生じる。正解は <body> 最上部に インラインブロッキングスクリプト を入れてスプラッシュノードを直接描くこと — JS を回さないボット HTML には残らず、最新の React は body 上部に挟み込んだ予想外ノードをハイドレーションで tolerant に飛ばすので mismatch もない。
やったこと。 SSR を捨ててインラインスクリプト注入に切り替えた。ハイドレーション後にクライアントが opacity でスプラッシュを取り除く。インラインスクリプトに文字列を入れるとき </script> の早期終了を防ぐため < を Unicode エスケープし textContent でセットした。
教訓。 初回ペイント前の表示 + ボット隠蔽はインラインブロッキングスクリプトが正解だ。共有レイアウトで headers()/cookies() を使う前に下位ルートの ISR 有無を確認せよ。
→ 初回ペイント前の表示 + ボット隠蔽 — インラインブロッキングスクリプト
2. オフラインファースト — 即時反映は staleTime ではなく invalidate
どんな状況だったか。 アプリ全ページをオフラインファーストにしつつ三つの要求を同時に満たす必要があった: (1) 読み取り即時性(リロードしてもすぐ出る)、(2) バックエンドトラフィック最小、(3) 自分の書き込みの即時反映。素直にやると三要求が衝突する — 即時性のためにキャッシュ鮮度時間を下げるとトラフィックが急増する。
核心概念。 二つの洞察が核心だった。
第一に、「自分の書き込みの即時反映」は staleTime を下げるのではなく書き込み後の invalidateQueries で作る。 staleTime はデータを何秒間鮮度があると信じてバックグラウンド refetch を我慢するかを決め、invalidateQueries はその staleTime を 無視して アクティブクエリを即座に再取得する。だから staleTime は鮮度 SLA(例: 10 秒)として維持しトラフィックを抑え、書き込みが成功したら関連クエリだけをスコープ無効化してその瞬間だけ強制 refetch する。楽観的更新で画面を先に変え失敗したらロールバックを載せれば体感の即時性まで確保される。
第二に、reload-durable なオフラインミューテーションキュー はリロードにも生き残らねばならない。オフラインでした書き込み(いいね・コメント)をローカルに積んでおいて再接続・リロード後にも再開されるには、persist がそのミューテーションキーを保存し、setMutationDefaults がマウントされたコンポーネントなしでも実行する resume 経路を与え、再構成に必要な値(対象 id など)を variables に載せねばならない。ミューテーションコールバックは mutationKey を読めないので、この三点のうち一つでも欠けると復元されても排出できない stuck キューになる。
やったこと。 キャッシュをローカル DB(IndexedDB)に persist(staleTime 維持、フォーカス refetch オフ)、楽観的書き込み + onError ロールバック、書き込み後のスコープ invalidate、setMutationDefaults の reload-durable キュー、読み取りルートに ETag/304、persist スナップショットのグループ別 LRU 追い出しを段階的に付けた。サーバー側 payload キャッシュは、詳細が既に ISR で覆われリストはクエリストリングの種類数(cardinality)が高くキャッシュヒットしないため過剰投資と判定してスキップした。ETag は帯域だけを節約しサーバー演算は節約できないという限界も確認した。
教訓。 即時反映は invalidate で、鮮度 SLA は staleTime で — 役割が違う。オフラインキューは persist + setMutationDefaults + variables の三点が揃わねばならない。persist・サーバーキャッシュ最適化の前に対象ページの ISR 有無とクエリストリング cardinality を先に見よ。
→ invalidateQueries vs staleTime — 自分の書き込みの即時反映