Skip to content

Sync and State

useOverlayStore の状態同期は 複数経路 を Adapter パターンで抽象化し、CompositeAdapter で 1 つに合成している。本ページは Adapter 構成 / 受信ループ防止 / 4 層分割 store の役割をまとめる。

ストア構造(4 層分割)

src/store/
├── useOverlayStore.ts      # create() で 4 層を合成(薄いエントリ)
├── index.ts                # 公開 API + initStoreSync / getActiveSyncAdapter
├── overlay/
│   ├── state.ts            # OverlayStore 型 + 初期値 + 派生関数
│   ├── actions.ts          # createOverlayActions(set/get を受ける)
│   ├── sync.ts             # createSyncBridge(broadcastSet / receive guard)
│   └── persist.ts          # createOverlayPersistOptions(zustand persist 設定)
└── sync/
    ├── adapter.ts          # SyncAdapter interface + NoopAdapter
    ├── electronAdapter.ts
    ├── broadcastAdapter.ts
    ├── wsAdapter.ts
    ├── localStorageAdapter.ts
    └── compositeAdapter.ts # 複数 adapter を 1 つに合成

Adapter インターフェース

ts
// src/store/sync/adapter.ts
export interface SyncEnvelope {
  payload: Partial<OverlayState>;
  sourceId?: string;
  timestamp?: number;
}

export interface SyncAdapter {
  start?: () => void;
  stop?: () => void;
  send: (envelope: SyncEnvelope) => void;
  onReceive: (fn: (envelope: SyncEnvelope) => void) => void;
}
  • send(envelope): 状態の差分を相手側へ送る
  • onReceive(fn): 受信時に fn を呼ぶよう登録(複数登録可)
  • start / stop: 接続管理(WS や IPC のリスナ登録 / 解除)

NoopAdapter は同期不要な環境向けの no-op 実装。

Adapter ごとの役割

Adapter用途トランスポート
ElectronAdapterElectron control / overlay window 間の同期window.electronAPI.syncConfig / onConfigUpdated(IPC)
BroadcastAdapter同一ブラウザ・同一 origin 内の複数タブBroadcastChannel
WSAdapterOBS ブラウザソース ↔ 開発サーバの相互通信WebSocket
LocalStorageAdapterOBS local file mode 等のフォールバックlocalStorage + storage イベント

CompositeAdapter

複数 adapter を 1 つの SyncAdapter として扱う ためのラッパ。

ts
// 抜粋: src/store/sync/compositeAdapter.ts
export class CompositeAdapter implements SyncAdapter {
  send(envelope: SyncEnvelope) {
    this.adapters.forEach(a => {
      try { a.send(envelope); }
      catch (e) { console.warn('[CompositeAdapter] send failed', e); }
    });
  }

  onReceive(fn: (e: SyncEnvelope) => void) {
    this.adapters.forEach(a => a.onReceive(fn));
  }
}
  • send: 全 adapter に fan-out(1 つ失敗しても他は続行)
  • onReceive: 同じコールバックを全 adapter に登録。どの経路で来ても setState が呼ばれる
  • start / stop: 全 adapter に伝播

環境ごとの合成例

initStoreSync() が現在の window の能力を判定して必要な adapter を組み立てる。renderer entry (main.tsx / overlay-main.tsx) で 1 度だけ呼べばよく、idempotent。

送受信フローと無限ループ防止

isReceiving フラグ

受信した変更を setState でストアに反映すると、subscribe している listener から 再度 broadcastSet が呼ばれる 危険がある。これを防ぐため overlay/sync.ts 内に モジュールスコープの isReceivingRef を持ち、受信処理中は broadcastSet 経由の send を skip する。

ts
// 抜粋: overlay/sync.ts
const isReceivingRef = { current: false };

function broadcastSet(diff: Partial<OverlayState>) {
  if (isReceivingRef.current) return;            // 受信中は送らない
  adapter.send({ payload: diff, sourceId: CLIENT_ID });
}

function onReceiveEnvelope(env: SyncEnvelope) {
  isReceivingRef.current = true;
  try { useOverlayStore.setState(env.payload); }
  finally { isReceivingRef.current = false; }
}

CLIENT_ID の役割

各 renderer プロセスで 一時的な IDcrypto.randomUUID() ベース)を持ち、SyncEnvelope.sourceId に詰める。これで自分が送ったメッセージが折り返されたかどうかを判別できる。

永続化 ID にしないのは、Electron の control / overlay window が同一 origin の localStorage を共有してしまい、両ウィンドウが同じ ID になって受信を自己送信扱いで捨ててしまうため。

lastSourcePerKey

キーごとに「最後に書き込んだ sourceId」を記録し、リフレクトループを補助的に抑止する(同じ sourceId からの古い envelope は ignore できる)。

永続化 (persist.ts)

zustandpersist middleware を使い、localStorageoverlay-storage キーに保存。

  • partialize で永続化対象を絞り込み(揮発状態は除外)
  • onRehydrateStorage で復元時に LAYOUT_DEFAULTS から欠落フィールドを補完
  • 旧バージョンの保存値とも互換になるよう、未知のフィールドは無視せず初期値で埋める

デバッグ用関数(renderer console)

関数役割
window.debugOverlay()現在の store snapshot を console に出す
window.dumpOverlayStorage()localStorageoverlay-storage を整形表示
window.clearOverlayStorage()overlay-storage を消す
window.applyLayoutDefaults()LAYOUT_DEFAULTS を強制適用

テスト戦略

  • src/store/__tests__/: store 公開 API のテスト
  • src/store/sync/__tests__/: 各 adapter の単体テスト(mock event を流す)
  • src/store/overlay/sync.test.ts: broadcastSet / receive guard / isReceiving フラグの境界
  • src/store/overlay/actions.test.ts: setter のロジック

initStoreSync(mockAdapter) で adapter を DI できるので、UI 側のテストでも分離可能。

関連ドキュメント


Last verified: 2026-05-06 / against commit e351b23a