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 インターフェース
// 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 | 用途 | トランスポート |
|---|---|---|
ElectronAdapter | Electron control / overlay window 間の同期 | window.electronAPI.syncConfig / onConfigUpdated(IPC) |
BroadcastAdapter | 同一ブラウザ・同一 origin 内の複数タブ | BroadcastChannel |
WSAdapter | OBS ブラウザソース ↔ 開発サーバの相互通信 | WebSocket |
LocalStorageAdapter | OBS local file mode 等のフォールバック | localStorage + storage イベント |
CompositeAdapter
複数 adapter を 1 つの SyncAdapter として扱う ためのラッパ。
// 抜粋: 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 する。
// 抜粋: 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 プロセスで 一時的な ID(crypto.randomUUID() ベース)を持ち、SyncEnvelope.sourceId に詰める。これで自分が送ったメッセージが折り返されたかどうかを判別できる。
永続化 ID にしないのは、Electron の control / overlay window が同一 origin の
localStorageを共有してしまい、両ウィンドウが同じ ID になって受信を自己送信扱いで捨ててしまうため。
lastSourcePerKey
キーごとに「最後に書き込んだ sourceId」を記録し、リフレクトループを補助的に抑止する(同じ sourceId からの古い envelope は ignore できる)。
永続化 (persist.ts)
zustand の persist middleware を使い、localStorage の overlay-storage キーに保存。
partializeで永続化対象を絞り込み(揮発状態は除外)onRehydrateStorageで復元時にLAYOUT_DEFAULTSから欠落フィールドを補完- 旧バージョンの保存値とも互換になるよう、未知のフィールドは無視せず初期値で埋める
デバッグ用関数(renderer console)
| 関数 | 役割 |
|---|---|
window.debugOverlay() | 現在の store snapshot を console に出す |
window.dumpOverlayStorage() | localStorage の overlay-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 側のテストでも分離可能。
関連ドキュメント
- 全体構成: Source Architecture
- IPC まわり: Electron Main
Last verified: 2026-05-06 / against commit e351b23a