TL;DR
- バグを 1 件ずつ patch すると別のバグが顔を出す状態でした
- 共通の根は「state Source of Truth が複数並立し、相互に整合しない瞬間がある」ことでした
- 対症療法の
if (isLocalMode) return;を散らすのを止め、Provider 階層で物理分離 に切り替えました - boot シーケンスを 1 つの async 関数 に集約し、各 component の useEffect の偶発的な発火順序に依存しないようにしました
- BootOverlay を一貫表示することで「画面のちらつき」を構造的に解消しました
- 学びは「対症療法はかえって解析を難しくする」ということでした
背景:8 つのバグが連鎖していた
niyase desktop(Electron + Vite + React + PGlite)のリリース直前期に、ローカル / Firebase のマルチアカウント機構と onboarding/移管フローを整備していた過程で、次のようなバグが連鎖的に発生しました。
- Firebase ID トークンキャッシュ → 403 ループ
- AccountMenu に同一ユーザーが 2 件並ぶ
idb://niyase-localに cloud namespace のデータが書き込まれる漏洩user-context:localに cloud スペースの UUID が残る- 個人スペース flag の cloud namespace への漏洩
- ログイン中のスプラッシュ・ローディング多重表示で画面がちらつく
/users/me/workspacesが複数 component から重複 fetch → dev で 429 / connection refused / 401 ループ- React の
Cannot update SidebarProvider while rendering UserContextProvider警告
各バグを 1 件ずつ patch すると、別のバグが新たに顔を出す状態でした。「コードがバグだらけで、リファクタが必要だ」と判断したところから始めました。
診断:State Source of Truth が並立していた
調べてみると、共通の根がありました。state の起点が 複数並立 していて、相互に整合しない瞬間があるのです。
| Layer | 名前 | 実体 | 変更タイミング |
|---|---|---|---|
| Layer 1 | registry.activeUserId | localStorage["niyase-accounts"] の JSON | setActiveUserId()(即時反映) |
| Layer 2 | openedAccountId | PGlite が開いた namespace | doInitMeta() で 1 回だけ(reload で再 mount) |
この 2 つは 必ずしも一致しません。
signup 直後の reload 前:
registry.activeUserId = <niyaseUserId> ← setActiveUserId 済み
openedAccountId = "local" ← まだ boot 時の値
「ローカル切替」直後の reload 前:
registry.activeUserId = "local" ← setActiveUserId 済み
openedAccountId = <niyaseUserId> ← まだ前回 boot 時の値
この 不一致の瞬間 に scoped key を書くと、「次回 reload 後の namespace」に書き込んでしまい、片方のアカウントが他方のデータで汚染される。これが過去の漏洩バグの根源でした。
解決 1:scoped key の anchor を PGlite namespace に固定
accountScopedKey(base) の anchor を openedAccountId(Layer 2)に固定:
// lib/account-storage.ts
export function accountScopedKey(base: string, accountId?: string): string {
if (accountId !== undefined) return `${base}:${accountId}`;
const opened = getOpenedAccountId(); // ← Layer 2 を優先
if (opened !== null) return `${base}:${opened}`;
return `${base}:${currentAccountId()}`; // boot 前のみ Layer 1 にフォールバック
}
これだけで user-context:<accountId> / active-workspace-id:<accountId> / personal-space-added:<accountId> などの scoped key が すべて「いま PGlite が触っているのと同じ namespace」 に書き込まれます。「ユーザーは local 切替したが PGlite はまだ cloud namespace」の瞬間に WorkspaceProvider が setWorkspace を発火しても、書き込み先は cloud namespace の scoped key になる → 漏洩しない。
レースコンディションが構造的に消えました。
解決 2:cloud API 呼び出しを Provider 階層で物理分離
「ローカル時に cloud API を呼ばないガード」を 5 つの component に散らしていた状態を止めました。Provider 階層で物理的に分離 することで、ローカル時は cloud API を呼ぶ component が そもそも mount されない 構造に。
<App>
<CurrentAccountProvider>
<CloudAwareGate>
{accountKind === "firebase" ? (
<CloudResourcesProvider> {/* Firebase 限定。cloud API 呼び出し集約 */}
<WorkspaceListProvider> {/* /users/me/workspaces を 1 回だけ */}
<SidebarPrefsProvider> {/* /users/me/sidebar-preferences (スペース単位) */}
<UserContextProvider>
<WorkspaceProvider>
<Routes>...</Routes>
</WorkspaceProvider>
</UserContextProvider>
</>
</CloudResourcesProvider>
) : (
<UserContextProvider>...</UserContextProvider>
)}
</CloudAwareGate>
</CurrentAccountProvider>
</App>
子 component で getOpenedAccountKind() === "firebase" のような実行時ガードを書く必要が消えました。mount されていなければ呼べないからです。
加えて、/users/me/workspaces を WorkspaceListProvider が 1 回だけ 取得して context で配るので、WorkspaceSwitcher と WorkspaceProvider の重複 fetch が構造的に消えました。
解決 3:boot シーケンスを 1 つの async 関数に集約
ログインからポータル着地までは、PGlite 初期化 → Firebase auth 復元 → sync pull → スペース DB open → portal mount と複数の処理が直列に走ります。旧設計では各 component の useEffect が偶発的な発火順序でこれを実現していて、デバッグが非常に困難でした。
bootSequence Orchestrator にまとめます:
// lib/boot-orchestrator.ts
let bootPromise: Promise<{ target: string }> | null = null;
export function bootSequence(): Promise<{ target: string }> {
if (bootPromise) return bootPromise; // React 18 StrictMode の double invoke 対策
bootPromise = doBootSequence();
return bootPromise;
}
async function doBootSequence(): Promise<{ target: string }> {
setBootPhase("auth-restore");
const user = await waitForFirebaseAuth();
let rows = await listLocalWorkspaces();
if (rows.length > 0) return { target: ... };
if (user) {
setBootPhase("sync-pull");
await getSyncManager().initialize(...);
rows = await listLocalWorkspaces();
if (rows.length > 0) return { target: ... };
}
return { target: await resolveNoLocalWs(!!user) };
}
呼び出し側 (RootRedirect) は 15 行 に簡素化:
function RootRedirect() {
const [target, setTarget] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
bootSequence()
.then(({ target }) => !cancelled && setTarget(target))
.catch(() => setTarget("/onboarding"));
return () => {
cancelled = true;
};
}, []);
if (!target) return null;
return <Navigate to={target} replace />;
}
3 つの効果がありました。
- 順序の可読性: 「PGlite init → auth restore → sync pull → スペース resolve」が一直線
- 副作用の冪等化: bootPromise キャッシュで StrictMode の double-invoke を吸収(sync.initialize が 2 回走らなくなる)
- エラーハンドリングが集中: try/catch を 1 箇所に
解決 4:BootOverlay で画面のちらつきを構造的に解消する
旧設計では、PGlite 初期化中、auth restore 中、sync pull 中、スペース DB open 中、portal mount 中で それぞれ独立した spinner が出ていて、reload や render を跨ぐとちらつきました。
1 つの BootOverlay で全 phase を覆う設計に:
// lib/boot-phase.ts
export type BootPhase =
| "idle"
| "auth-signin"
| "auth-restore"
| "pglite-init"
| "sync-pull"
| "ws-open"
| "ready";
export function setBootPhase(phase: BootPhase, reason?: string): void {
console.log(`[Boot] phase: ${prev} → ${phase} | ${reason}`);
sessionStorage.setItem("boot-phase", phase); // reload を跨いで継続
listeners.forEach((l) => l(phase));
}
<BootOverlay> が phase を購読して ready 以外の間ずっと全画面 overlay を表示。phase 遷移時に メッセージとプログレスバーだけ差し替わる ので、画面はずっと同じ overlay で覆われ続けます。「ログインしています...」→「クラウドからデータを取得しています...」→「スペースを開いています...」と進捗が読み取れる体験になりました。
解決 5:React の pure updater ルールに準拠
旧設計の UserContextProvider.setWorkspace:
setContextState((prev) => {
const merged = { ...prev, workspaceId: id, ... };
persistUserContext(merged); // ← 副作用
setActiveWorkspaceId(id); // ← 副作用
window.dispatchEvent(...); // ← render 中の dispatchEvent
return merged;
});
これは React 19+ の pure updater ルール違反で、Cannot update SidebarProvider while rendering UserContextProvider 警告の原因。
純粋化:
const setWorkspace = useCallback((id, slug, name, role, type) => {
setContextState((prev) => ({ ...prev, workspaceId: id, ... })); // pure
}, []);
useEffect(() => {
if (!hydratedRef.current) { hydratedRef.current = true; return; }
persistUserContext(context);
if (context.workspaceId) setActiveWorkspaceId(context.workspaceId);
}, [context]);
useEffect(() => {
if (prevWorkspaceIdRef.current === context.workspaceId) return;
prevWorkspaceIdRef.current = context.workspaceId;
if (context.workspaceId) window.dispatchEvent(new CustomEvent("workspace-changed"));
}, [context.workspaceId]);
setContextState の updater は state を計算するだけ、副作用は useEffect で。warning が消え、code は読みやすくなりました。
観測性:重要な判定経路は本番でも console.log を残す
バグの早期発見のため、重要な判定経路には 本番ビルドでも残す console.log を配置:
[Boot] phase: X → Y | reason
[bootSequence] firebase auth resolved { hasUser, uid, email }
[Account-Type] WorkspaceSwitcher.resolve { workspaceId, isPersonal, isSyncable }
[Account-Type] UserContextProvider.workspaceChanged { ... }
[WorkspaceListProvider] /users/me/workspaces fetched { count }
[switchAccount] start { currentRegistryKind, currentOpenedKind, targetKind }
[PGlite] Opening meta data dir: idb://niyase-XXX (account=XXX)
これで「アカウント種別の判定が間違っている」「想定外の namespace で書き込みが走った」を 後追いではなく現場で 検知できるようになりました。
構造的に消えたバグ
| 旧バグ | 防止メカニズム |
|---|---|
| ローカル切替後も cloud スペース が見える | scoped key を openedAccountId anchor 化 |
| 個人スペース flag の漏洩 | 同上 |
/users/me/workspaces 重複 fetch / 401 ループ | Provider 階層で Firebase 限定。ローカル時は Provider 自体が mount されない |
WorkspaceProvider レースコンディションで user-context:local 汚染 | scoped key anchor が PGlite namespace 基準 |
Cannot update SidebarProvider while rendering UserContextProvider 警告 | updater pure + useEffect で副作用 |
| ログイン直後の画面のちらつき | BootOverlay の一貫表示 |
「if ガードを散らす」のではなく「構造上不可能にする」という設計です。1 つずつ手で防いでいたものが、根本から消えていきます。
教訓
- 対症療法はかえって解析を難しくする
- 「ローカル時に呼ばないガード」を component 5 箇所に散らす形は、component を追加するたびに必ず漏れます
- Provider 階層で物理的に分離する方が安全です
- state Source of Truth は 1 つに集約する
- 2 つ並立すると「不一致の瞬間」が必ず生まれます
- 本件は registry と openedAccountId の 2 層ですが、scoped key の anchor を後者に固定することで「実体としては 1 つ」に近づけました
- React 19+ の pure updater ルールに従う
- updater 内の副作用は警告の対象になり、StrictMode で 2 回走ります
- useEffect で外に出します
- boot シーケンスは 1 つの async 関数で記述する
- useEffect の偶発的な発火順序に依存すると、デバッグが非常に困難になります
- 逐次的なシーケンスを 1 つの関数に集約します
- 観測ログは本番でも残す
- 「壊れたか分からない」状態が最も困ります
[Boot] phase: X → Yを見れば現場で診断できます
まとめ
8 個のバグを 1 つずつ patch するのではなく、構造で消す 設計に切り替えました。新規ファイル 9 つ + 既存 10 数ファイルの修正、合計 3 日。
「リファクタは大きいほど不安だ」と感じる気持ちは分かりますが、対症療法を続けると「次のバグでまた壊れる」という不安が常につきまといます。一度根本から整理する方が、長い目で見ると安全 という体験でした。
設計原則は社内の設計ドキュメントに明文化しました。今後の追加修正で同じパターンが再導入されないようにしています。