PWAロゴとアプリのアイコンが描かれた回路基板の背景に、開発者がデータコアチップを持つ。アプリケーションモジュールがIndexedDBと連携し、「APIダウン」時にIndexedDBが「OK」となり、ログイン画面を経由せずダッシュボードUIを表示する様子が図示されている。PWAのオフラインログイン問題解決とIndexedDBスナップショット利用の概念を視覚的に表現しています。

はじめに

PWA(Progressive Web App)を運用していると、「オンライン時に一度使ったはずのアプリが、圏外や低速回線で再訪した瞬間にログイン画面へ落ちてしまう」という問題にぶつかることがあります。

原因の多くは、初期表示用のAPI(ダッシュボードなどを描画するための集約データを返すエンドポイント)が失敗したときに、アプリ側がそのまま「未ログイン」として扱ってしまう実装にあります。ネットワークが不安定なだけで、セッション自体は生きているはずなのに、です。

この記事では、起動時に取得した集約データをIndexedDBへ非同期で保存しておき、次回起動時にネットワーク応答を待たずにそのスナップショットから即時描画する、いわゆるstale-while-revalidateをアプリ全体のブートストラップに適用する設計を紹介します。あわせて、複数モジュールが同じIndexedDBを共有するときに踏みやすい落とし穴も整理します。

こんな人におすすめ

  • PWAやSPAで、オフライン再訪時にログイン画面へ落ちてしまう挙動に悩んでいる方
  • 複数の機能モジュールから同じIndexedDBを扱っていて、スキーマ管理に不安がある方
  • ReactのuseStateで非同期処理の完了判定をしていて、StrictModeの二重マウントに振り回された経験がある方
  • Service Workerを前提にしないオフラインE2Eテストの書き方を探している方

罠1: 複数モジュールが同じIndexedDBを取り合うとストアが消える

オフライン同期キューとスナップショット保存のように、複数の機能モジュールがそれぞれ個別にindexedDB.open(name, version)を呼んでいると、先に開いたモジュールのonupgradeneededが後から追加されたストアの生成ロジックを知らない、という状況が発生します。

初期化の順序によってはストア自体が欠落するバグを踏みやすく、しかも再現条件が曖昧なため気づきにくいのが厄介なところです。

DB名・バージョン・ストア生成を1つのモジュールに集約し、他のモジュールはそこからre-exportされた関数だけを使う構成にすると、この事故はかなり減らせます。

// 複数モジュール(同期キュー / スナップショット保存)が同じDBを安全に共有するための単一窓口
export const OFFLINE_DB_NAME = 'app-offline'
export const PENDING_MUTATIONS_STORE = 'pending-mutations'
export const SNAPSHOT_STORE = 'bootstrap-snapshot'

const DB_VERSION = 2
let cachedDB: IDBDatabase | null = null

export function openOfflineDB(): Promise<IDBDatabase> {
  if (cachedDB) return Promise.resolve(cachedDB)
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(OFFLINE_DB_NAME, DB_VERSION)

    request.onupgradeneeded = () => {
      const db = request.result
      if (!db.objectStoreNames.contains(PENDING_MUTATIONS_STORE)) {
        const store = db.createObjectStore(PENDING_MUTATIONS_STORE, { keyPath: 'id', autoIncrement: true })
        store.createIndex('userId', 'userId', { unique: false })
      }
      if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) {
        const store = db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'key' })
        store.createIndex('userId', 'userId', { unique: false })
      }
    }

    request.onsuccess = () => {
      cachedDB = request.result
      cachedDB.onversionchange = () => { cachedDB?.close(); cachedDB = null }
      resolve(cachedDB)
    }
    request.onerror = () => reject(request.error)
  })
}

ポイントはobjectStoreNames.contains(...)で毎回ガードしてからcreateObjectStoreすることです。こう書いておけば、スキーマバージョンを1から2に上げても既存ストアを壊さずに新しいストアだけを追加できます。onupgradeneededは「毎回冪等に書く」ことを前提に設計しておくと、後からモジュールが増えても安心です。

罠2: Reactのstateだけで復元判定すると二重発火する

「まだデータが無い場合だけスナップショットを復元する」という判定を、ReactのuseStateだけで行うのは危険です。setStateの反映は非同期なので、StrictModeの二重マウント(1回目の呼び出しはabortされる想定)で、意図せず復元処理が二重に走ってしまう可能性があります。

これを避けるには、useRefで同期的に最新値を追跡し、それを条件に使う方法が有効です。

const dataRef = useRef<Payload | null>(null)
useEffect(() => { dataRef.current = data }, [data])

const setDataAndRef = useCallback((value: Payload | null) => {
  dataRef.current = value
  setData(value)
}, [])

// 初回ロード(まだ data が無い)に限り、ネットワーク応答を待たずスナップショットで即時描画する
if (dataRef.current === null) {
  try {
    const snapshot = await loadSnapshot(childId)
    if (snapshot && loadSequence === loadSequenceRef.current && dataRef.current === null) {
      setDataAndRef(snapshot.payload)
      markAuthenticated()
      setLoading(false)
    }
  } catch {
    // 復元失敗はオフライン描画の可用性を損なわない。通常のネットワーク経路へフォールバックする。
  }
}

dataRef.current === nullをawaitの前後で2回チェックしているのがポイントです。非同期処理の実行中に、別の呼び出しが先にデータを埋めていた場合でも、後から来た呼び出しがそれを上書きしないようにできます。「実際に勝った呼び出しだけが復元を適用する」という設計です。

罠3: セッションフラグの目的外流用で復元条件を見誤る

復元すべきかどうかの判定に、「クライアント側で保持しているセッション情報の有無」を示すフラグを流用してしまうケースも見かけます。

一見それらしく見えるのですが、そのフラグが実は招待フローのOTPセッションのような限定用途専用で、通常ログインではほぼ常にfalseになる、という設計になっていることがあります。目的が違うフラグを判定に使うと、本来復元してよいはずの場面で復元がスキップされてしまいます。

復元要否の判定には、「ローカルにスナップショットが存在すること自体」のような、目的に沿った独立のシグナルを使うほうが意図に合います。既存のフラグを「たまたま使えそうだから」という理由で流用しないことが、この手のバグを避ける一番の近道です。

オフラインE2Eの再現: Service Worker無しで「APIだけ落ちる」状態を作る

保存処理を呼び出し元でawaitしない、いわゆるfire-and-forgetな設計にすると(描画をブロックしないための判断です)、E2Eテストで「保存が終わるまで待つ」手段が別途必要になります。expect.pollで件数を監視するヘルパーを用意しておくと、この手のテストは安定します。

またオフライン再現そのものにも注意が必要です。Service Workerを登録していないテスト環境でcontext.setOfflineを使うと、SW不在によってドキュメント自体の取得が失敗し、SPAが読み込めなくなってしまいます。

代わりに、/api/**だけをネットワークエラーで落とし、navigator.onLineを上書きする方法を使うと、「APIだけ届かない」状態を安全に再現できます。

await page.addInitScript(() => {
  Object.defineProperty(window.navigator, 'onLine', { configurable: true, get: () => false })
})
await page.route('**/api/**', (route) => route.abort('failed'))

await reloadApp(page)
// ログイン画面ではなく前回のダッシュボードが即時描画され、オフラインバナーが出る

SPAのシェル自体は正常に読み込ませつつ、API疎通だけを落とせるのがこのアプローチの利点です。

つまづきやすいポイント

  • IndexedDBの初期化を分散させない: モジュールごとにindexedDB.openを呼ぶと、初期化順序次第でストアが欠落します。接続とスキーマ生成は1箇所に集約しましょう
  • stateだけで非同期の一回性を保証しない: useRefで同期的に判定しないと、StrictModeの二重マウントなどで想定外の二重実行が起こり得ます
  • 既存フラグの目的外流用は避ける: 「それらしく使えそうなフラグ」ほど、実は別用途専用ということがあります
  • ログアウト時はローカルキャッシュも消す: 端末を複数ユーザーで共有する可能性があるアプリでは、ログアウトや認証失効のタイミングでローカルの保存データも削除しないと、次の利用者の画面に前の利用者のデータが一瞬表示されるリスクがあります

まとめ

オフライン再訪時の「ログイン画面への意図しない転落」は、初期表示APIの失敗を未ログインと同一視してしまうことが根本原因になっているケースが少なくありません。

集約データをIndexedDBにスナップショットとして保存し、次回起動時にネットワーク応答を待たずそこから即時描画する設計にすると、この問題は解消に向かいます。

ただし複数モジュールでのIndexedDB共有、Reactのstateだけに頼った非同期判定、目的外流用したフラグでの判定は、いずれも気づきにくい形でバグを生みます。スキーマ生成の一元化、useRefによる同期判定、独立したシグナルでの復元判定という3点を押さえておくと、オフライン対応の実装はぐっと安定するはずです。