コンテンツにスキップ

Storefront HMR シングルトンパターン

1. 背景

apps/storefrontVite + React Router SPA であり、開発中は Vite の HMR(Hot Module Replacement)が使われる。

HMR でモジュールが再評価されると、モジュールスコープの変数(let/const)は初期値へ戻る。useRef もコンポーネントが再 mount されれば再初期化される。

この問題が顕在化したのは TanStack Query の QueryClient であった。StorefrontQueryProvideruseRefQueryClient を保持していたため、HMR 後にキャッシュが消えて認証状態 (["activeCustomerContext"]) が anonymous に倒れ、お気に入りなど認証依存クエリが silent fail した。

2. 採用パターン: import.meta.hot.data

Vite 公式の HMR persistence API。HMR モジュール置換をまたいで任意の値を保持できる。本番ビルドでは import.meta.hotundefined になるため、ガード内のコードは dead-code として除去される。

type StorefrontQueryClient = ReturnType<typeof createStorefrontQueryClient>;

function getOrCreateQueryClient(enableDebugLogging: boolean): StorefrontQueryClient {
  if (import.meta.hot) {
    const cached = import.meta.hot.data.queryClient as StorefrontQueryClient | undefined;
    if (cached) return cached;
    const client = createStorefrontQueryClient({ enableDebugLogging });
    import.meta.hot.data.queryClient = client;
    return client;
  }
  return createStorefrontQueryClient({ enableDebugLogging });
}

export function StorefrontQueryProvider({ children }) {
  const { enableDebugLogging } = useStorefrontConfig();
  const [queryClient] = useState(() => getOrCreateQueryClient(enableDebugLogging));
  // ...
}

実装: apps/storefront/src/components/providers/storefront-query-provider.tsx

パターンの構成要素

要素 理由
import.meta.hot.data Vite 公式 HMR persistence。モジュール再評価をまたいで値が生存
useState(() => ...) TanStack Query 公式 SSR パターン。コンポーネント mount 中の安定性を保証
本番フォールバック import.meta.hotundefined の場合は通常の生成パスのみを通る

3. 適用判断基準

HMR 後にユーザー可視の失敗(データ取得停止、認証状態リセット)を引き起こすシングルトンが対象。以下の基準で判断する。

状況 対応
失うと機能が停止する状態(認証キャッシュ、アクティブセッションなど) import.meta.hot.data で保護
失っても再構築できるキャッシュ(TTL 付きパフォーマンスキャッシュなど) 保護不要
useEffect 内の addEventListener React が cleanup を管理するため安全
window フラグ付きの一度だけ実行されるパッチ フラグが window に残るため安全

4. 既存コードの監査結果(2026-05 時点)

HMR 後のユーザー可視失敗を引き起こす箇所を全件調査した結果。

対応済み

ファイル 内容 対応
src/components/providers/storefront-query-provider.tsx QueryClientuseRef で保持 import.meta.hot.data + useState へ変更

保護不要と判定

ファイル 内容 理由
src/lib/visible-products.ts:76 visibleSearchResultCache (Map) TTL 15s のパフォーマンスキャッシュ。HMR で消えても次アクセスで再構築される
src/lib/analytics/ga4.ts:53 window.dataLayer ??= [] 関数内の初期化。??= で既存配列を保持するため HMR 安全
src/components/providers/storefront-replay-controller.tsx:29,53 history patch / addEventListener window[HISTORY_PATCHED_FLAG] ガード済み / cleanup 関数つき外部ストア購読
src/mocks/browser.ts:22 window.__mswWorkerStarted 開発用モック専用。機能失敗にならない

5. 新規シングルトンを追加する際のルール

モジュールスコープに状態を持つオブジェクト(クライアント、Map、Promise)を追加する場合:

  1. HMR で失われた際にユーザー可視の失敗が起きるか確認する
  2. 起きる場合は import.meta.hot.data パターンを採用する
  3. import.meta.hot.data のキーはモジュール間で衝突しないよう __ritsubi<ModuleName><ObjectName>__ 形式にする
  4. TypeScript 型は as Type | undefined でキャストする(import.meta.hot.dataany 型のため)

6. 参考