From 1505274f941c15d18df9d142c7d504e6bf654581 Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Tue, 30 Jun 2026 04:47:52 -0400 Subject: [PATCH] perf(stores): defer safeStorage writes off the interaction path (#1941) * perf(stores): defer safeStorage writes off the interaction path Session switches funnel every persisted store slice through safeStorage.setItem, and doing those large JSON.stringify writes synchronously blocked the main thread for over a second. Add a write-behind buffer that: - Defers each setItem/removeItem to a later task via setTimeout(0) so the click-to-paint path is not blocked. - Coalesces repeated writes to the same key into a single backing flush. - Serves pending values from memory so read-after-write stays consistent within the deferral window. - Flushes synchronously on pagehide/beforeunload/visibilitychange/freeze so deferred state survives tab close, reload, and the mobile freeze lifecycle. Adds a test covering write deferral, coalescing, and pending read serving. * fix(stores): defer persisted JSON serialization * fix(stores): defer direct safeStorage writes --------- Co-authored-by: Bohdan Triapitsyn --- .../src/components/session/SessionSidebar.tsx | 10 +- .../components/update/OpenCodeUpdateToast.tsx | 6 +- packages/ui/src/hooks/usePwaInstallPrompt.ts | 6 +- packages/ui/src/lib/directoryShowHidden.ts | 6 +- .../ui/src/lib/filesViewShowGitignored.ts | 6 +- packages/ui/src/stores/contextStore.ts | 6 +- packages/ui/src/stores/messageQueueStore.ts | 6 +- packages/ui/src/stores/permissionStore.ts | 6 +- packages/ui/src/stores/useAgentsStore.ts | 6 +- packages/ui/src/stores/useAutoReviewStore.ts | 6 +- packages/ui/src/stores/useCommandsStore.ts | 6 +- packages/ui/src/stores/useConfigStore.test.ts | 16 ++ packages/ui/src/stores/useConfigStore.ts | 6 +- packages/ui/src/stores/useDirectoryStore.ts | 4 +- .../ui/src/stores/useFilesViewTabsStore.ts | 6 +- .../ui/src/stores/useGitHubPrStatusStore.ts | 6 +- .../ui/src/stores/useGitIdentitiesStore.ts | 6 +- packages/ui/src/stores/useGitStore.ts | 6 +- .../src/stores/useInlineCommentDraftStore.ts | 6 +- packages/ui/src/stores/useMcpConfigStore.ts | 6 +- packages/ui/src/stores/usePluginsStore.ts | 6 +- packages/ui/src/stores/useProjectsStore.ts | 4 +- .../src/stores/useSessionFoldersStore.test.ts | 1 + .../ui/src/stores/useSessionFoldersStore.ts | 4 +- .../ui/src/stores/useSessionPinnedStore.ts | 4 +- packages/ui/src/stores/useSkillsStore.ts | 6 +- .../ui/src/stores/useTodosPersistStore.ts | 6 +- packages/ui/src/stores/useUIStore.ts | 6 +- .../ui/src/stores/utils/safeStorage.test.ts | 113 ++++++++++ packages/ui/src/stores/utils/safeStorage.ts | 193 ++++++++++++++++++ packages/ui/src/sync/selection-store.ts | 6 +- packages/ui/src/sync/session-ui-store.ts | 8 +- 32 files changed, 406 insertions(+), 83 deletions(-) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index eefb1666..af841951 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -13,7 +13,7 @@ import { useSync } from '@/sync/use-sync'; import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore'; import { isVSCodeRuntime } from '@/lib/desktop'; import { NewWorktreeDialog } from './NewWorktreeDialog'; @@ -188,7 +188,7 @@ export const SessionSidebar: React.FC = ({ const [editTitle, setEditTitle] = React.useState(''); const [editingProjectDialogId, setEditingProjectDialogId] = React.useState(null); const [expandedParents, setExpandedParents] = React.useState>(new Set()); - const safeStorage = React.useMemo(() => getSafeStorage(), []); + const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []); const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set()); const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); @@ -207,7 +207,7 @@ export const SessionSidebar: React.FC = ({ const togglePinnedSession = useSessionPinnedStore((state) => state.toggle); const [collapsedGroups, setCollapsedGroups] = React.useState>(() => { try { - const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY); + const raw = getDeferredSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY); if (!raw) { return new Set(); } @@ -219,7 +219,7 @@ export const SessionSidebar: React.FC = ({ }); const [groupOrderByProject, setGroupOrderByProject] = React.useState>(() => { try { - const raw = getSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY); + const raw = getDeferredSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY); if (!raw) { return new Map(); } @@ -237,7 +237,7 @@ export const SessionSidebar: React.FC = ({ }); const [activeSessionByProject, setActiveSessionByProject] = React.useState>(() => { try { - const raw = getSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY); + const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY); if (!raw) { return new Map(); } diff --git a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx index 7a4a321c..8d1a3732 100644 --- a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx +++ b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx @@ -6,7 +6,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { updateDesktopSettings } from '@/lib/persistence'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { resolveOpenCodeUpdateVersion, resolveOpenCodeUpgradeStatusVersion, @@ -100,7 +100,7 @@ export const OpenCodeUpdateToast: React.FC = () => { } const decision = shouldShowOpenCodeUpdateToast({ version, - dismissedVersion: getSafeStorage().getItem(UPDATE_TOAST_DISMISSED_VERSION_KEY), + dismissedVersion: getDeferredSafeStorage().getItem(UPDATE_TOAST_DISMISSED_VERSION_KEY), seenVersions: seenVersionsRef.current, }); if (!decision) { @@ -119,7 +119,7 @@ export const OpenCodeUpdateToast: React.FC = () => { cancel: { label: t('opencodeUpdate.toast.actions.dismiss'), onClick: () => { - getSafeStorage().setItem(UPDATE_TOAST_DISMISSED_VERSION_KEY, version); + getDeferredSafeStorage().setItem(UPDATE_TOAST_DISMISSED_VERSION_KEY, version); void updateDesktopSettings({ openCodeUpdateToastDismissedVersion: version }); toast.dismiss(UPDATE_TOAST_ID); }, diff --git a/packages/ui/src/hooks/usePwaInstallPrompt.ts b/packages/ui/src/hooks/usePwaInstallPrompt.ts index 7f2f829c..5c424cb1 100644 --- a/packages/ui/src/hooks/usePwaInstallPrompt.ts +++ b/packages/ui/src/hooks/usePwaInstallPrompt.ts @@ -3,7 +3,7 @@ import { toast } from '@/components/ui'; import { isWebRuntime } from '@/lib/desktop'; import { usePwaDetection } from '@/hooks/usePwaDetection'; import { useI18n } from '@/lib/i18n'; -import { getSafeSessionStorage, getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage, getSafeSessionStorage } from '@/stores/utils/safeStorage'; import { shouldShowPwaInstallToast } from '@/components/update/openCodeUpdateDedup'; type InstallPromptOutcome = 'accepted' | 'dismissed'; @@ -66,7 +66,7 @@ export const usePwaInstallPrompt = () => { installEvent.preventDefault(); deferredPrompt = installEvent; - const localStorage = getSafeStorage(); + const localStorage = getDeferredSafeStorage(); const sessionStorage = getSafeSessionStorage(); const decision = shouldShowPwaInstallToast({ dismissed: localStorage.getItem(INSTALL_TOAST_DISMISSED_KEY), @@ -90,7 +90,7 @@ export const usePwaInstallPrompt = () => { cancel: { label: tRef.current('pwa.installPrompt.dismiss'), onClick: () => { - getSafeStorage().setItem(INSTALL_TOAST_DISMISSED_KEY, 'true'); + getDeferredSafeStorage().setItem(INSTALL_TOAST_DISMISSED_KEY, 'true'); dismissInstallToast(); }, }, diff --git a/packages/ui/src/lib/directoryShowHidden.ts b/packages/ui/src/lib/directoryShowHidden.ts index 47b9c116..0282b1b8 100644 --- a/packages/ui/src/lib/directoryShowHidden.ts +++ b/packages/ui/src/lib/directoryShowHidden.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { updateDesktopSettings } from '@/lib/persistence'; const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden'; @@ -10,7 +10,7 @@ const readStoredShowHidden = (): boolean => { return true; } try { - const stored = getSafeStorage().getItem(SHOW_HIDDEN_STORAGE_KEY); + const stored = getDeferredSafeStorage().getItem(SHOW_HIDDEN_STORAGE_KEY); if (stored === null) { return true; } @@ -35,7 +35,7 @@ export const setDirectoryShowHidden = ( return; } try { - getSafeStorage().setItem(SHOW_HIDDEN_STORAGE_KEY, value ? 'true' : 'false'); + getDeferredSafeStorage().setItem(SHOW_HIDDEN_STORAGE_KEY, value ? 'true' : 'false'); notifyDirectoryShowHiddenChanged(); } catch { // ignore storage errors diff --git a/packages/ui/src/lib/filesViewShowGitignored.ts b/packages/ui/src/lib/filesViewShowGitignored.ts index 416d667e..210e517f 100644 --- a/packages/ui/src/lib/filesViewShowGitignored.ts +++ b/packages/ui/src/lib/filesViewShowGitignored.ts @@ -1,6 +1,6 @@ import React from 'react'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { updateDesktopSettings } from '@/lib/persistence'; const SHOW_GITIGNORED_STORAGE_KEY = 'filesViewShowGitignored'; @@ -11,7 +11,7 @@ const readStoredShowGitignored = (): boolean => { return false; } try { - const stored = getSafeStorage().getItem(SHOW_GITIGNORED_STORAGE_KEY); + const stored = getDeferredSafeStorage().getItem(SHOW_GITIGNORED_STORAGE_KEY); return stored === 'true'; } catch { return false; @@ -33,7 +33,7 @@ export const setFilesViewShowGitignored = ( return; } try { - getSafeStorage().setItem(SHOW_GITIGNORED_STORAGE_KEY, value ? 'true' : 'false'); + getDeferredSafeStorage().setItem(SHOW_GITIGNORED_STORAGE_KEY, value ? 'true' : 'false'); notifyFilesViewShowGitignoredChanged(); } catch { // ignore storage errors diff --git a/packages/ui/src/stores/contextStore.ts b/packages/ui/src/stores/contextStore.ts index 09c37383..ad824f15 100644 --- a/packages/ui/src/stores/contextStore.ts +++ b/packages/ui/src/stores/contextStore.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { EditPermissionMode } from "./types/sessionTypes"; import { getAgentDefaultEditPermission } from "./utils/permissionUtils"; import { extractTokensFromMessage } from "./utils/tokenUtils"; import { calculateContextUsage } from "./utils/contextUtils"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; interface ContextUsage { totalTokens: number; @@ -449,7 +449,7 @@ export const useContextStore = create()( }), { name: "context-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ sessionModelSelections: Array.from(state.sessionModelSelections.entries()), sessionAgentSelections: Array.from(state.sessionAgentSelections.entries()), diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index d27862a6..ae964feb 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import type { AttachedFile } from './types/sessionTypes'; import { updateDesktopSettings } from '@/lib/persistence'; @@ -201,7 +201,7 @@ export const useMessageQueueStore = create()( { name: 'message-queue-store', version: 1, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ queuedMessages: state.queuedMessages, followUpBehavior: state.followUpBehavior, diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index 6f54c5e2..6ac01416 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -1,11 +1,11 @@ import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { Session } from "@opencode-ai/sdk/v2/client"; import { autoRespondsPermission, type PermissionAutoAcceptMap, } from "./utils/permissionAutoAccept"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { getAllSyncSessions, getSyncChildStores } from "@/sync/sync-refs"; import { opencodeClient } from "@/lib/opencode/client"; import { respondToPermission } from "@/sync/session-actions"; @@ -201,7 +201,7 @@ const autoRespondsPermissionBySession = ( }); }; -const getStorage = () => createJSONStorage(() => getSafeStorage()); +const getStorage = () => createDeferredSafeJSONStorage(); export const usePermissionStore = create()( devtools( diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index d9bd2077..0b9cfef9 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { Agent, PermissionConfig } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "@/lib/opencode/client"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges, type ConfigChangeScope } from "@/lib/configSync"; @@ -9,7 +9,7 @@ import { finishConfigUpdate, updateConfigUpdateMessage, } from "@/lib/configUpdate"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { useConfigStore } from "@/stores/useConfigStore"; import { invalidateCommandsLoadCache, useCommandsStore } from "@/stores/useCommandsStore"; import { useProjectsStore } from "@/stores/useProjectsStore"; @@ -556,7 +556,7 @@ export const useAgentsStore = create()( }), { name: "agents-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedAgentName: state.selectedAgentName, }), diff --git a/packages/ui/src/stores/useAutoReviewStore.ts b/packages/ui/src/stores/useAutoReviewStore.ts index b5da3f19..fe4f5acb 100644 --- a/packages/ui/src/stores/useAutoReviewStore.ts +++ b/packages/ui/src/stores/useAutoReviewStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage'; type AutoReviewPhase = 'waiting_for_reviewer' | 'waiting_for_implementer'; type AutoReviewStatus = 'running' | 'completed' | 'stopped' | 'error'; @@ -88,7 +88,7 @@ export const useAutoReviewStore = create()( }), { name: 'auto-review-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ runsByOriginalSessionID: state.runsByOriginalSessionID }), }, ), diff --git a/packages/ui/src/stores/useCommandsStore.ts b/packages/ui/src/stores/useCommandsStore.ts index 517a0567..9e1dc608 100644 --- a/packages/ui/src/stores/useCommandsStore.ts +++ b/packages/ui/src/stores/useCommandsStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import { opencodeClient } from "@/lib/opencode/client"; import { startConfigUpdate, @@ -8,7 +8,7 @@ import { updateConfigUpdateMessage, } from "@/lib/configUpdate"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { useProjectsStore } from "@/stores/useProjectsStore"; import { runtimeFetch } from "@/lib/runtime-fetch"; @@ -427,7 +427,7 @@ export const useCommandsStore = create()( }), { name: "commands-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedCommandName: state.selectedCommandName, }), diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index ca4562c0..0b2b18c6 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -129,7 +129,23 @@ const deferred = () => { }; mock.module('@/stores/utils/safeStorage', () => ({ + getDeferredSafeStorage: () => makeStorage(), getSafeStorage: () => makeStorage(), + createDeferredSafeJSONStorage: () => { + const testStorage = makeStorage(); + return { + getItem: (name: string) => { + const value = testStorage.getItem(name); + return value === null ? null : JSON.parse(value); + }, + setItem: (name: string, value: unknown) => { + testStorage.setItem(name, JSON.stringify(value)); + }, + removeItem: (name: string) => { + testStorage.removeItem(name); + }, + }; + }, })); mock.module('@/stores/useProjectsStore', () => ({ diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index ae0f3131..88b808a8 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -1,11 +1,11 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { Provider, Agent, Config } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "@/lib/opencode/client"; import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import type { ModelMetadata } from "@/types"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { filterVisibleAgents } from "./useAgentsStore"; import { isPrimaryMode } from "@/components/chat/mobileControlsUtils"; import { useSessionUIStore } from "@/sync/session-ui-store"; @@ -3283,7 +3283,7 @@ export const useConfigStore = create()( }), { name: "config-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), merge: (persistedState, currentState) => hydrateActiveDirectorySnapshot({ ...currentState, diff --git a/packages/ui/src/stores/useDirectoryStore.ts b/packages/ui/src/stores/useDirectoryStore.ts index ea7d59ff..b0b32af5 100644 --- a/packages/ui/src/stores/useDirectoryStore.ts +++ b/packages/ui/src/stores/useDirectoryStore.ts @@ -6,7 +6,7 @@ import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { updateDesktopSettings } from '@/lib/persistence'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; interface DirectoryStore { @@ -28,7 +28,7 @@ interface DirectoryStore { let cachedHomeDirectory: string | null = null; let homeResolveGeneration = 0; -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); const persistedLastDirectory = safeStorage.getItem('lastDirectory'); const initialHasPersistedDirectory = typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0; diff --git a/packages/ui/src/stores/useFilesViewTabsStore.ts b/packages/ui/src/stores/useFilesViewTabsStore.ts index 1f391cf0..b069f507 100644 --- a/packages/ui/src/stores/useFilesViewTabsStore.ts +++ b/packages/ui/src/stores/useFilesViewTabsStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { createJSONStorage, devtools, persist } from 'zustand/middleware'; +import { devtools, persist } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; type RootTabsState = { openPaths: string[]; @@ -451,7 +451,7 @@ export const useFilesViewTabsStore = create()( { name: 'files-view-tabs-store', version: 2, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), migrate: (persistedState) => { if (!persistedState || typeof persistedState !== 'object') { return { byRoot: {} }; diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index 92933750..0dd0b24d 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -1,8 +1,8 @@ import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; +import { persist } from 'zustand/middleware'; import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types'; import { mapWithConcurrency } from '@/lib/concurrency'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; const PR_REVALIDATE_TTL_MS = 90_000; const PR_REVALIDATE_INTERVAL_MS = 15_000; @@ -647,7 +647,7 @@ export const useGitHubPrStatusStore = create()( }), { name: PR_STATUS_STORAGE_KEY, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ entries: Object.fromEntries( Object.entries(state.entries) diff --git a/packages/ui/src/stores/useGitIdentitiesStore.ts b/packages/ui/src/stores/useGitIdentitiesStore.ts index 917a049a..d43b2cda 100644 --- a/packages/ui/src/stores/useGitIdentitiesStore.ts +++ b/packages/ui/src/stores/useGitIdentitiesStore.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import { getSafeStorage } from "./utils/safeStorage"; +import { devtools, persist } from "zustand/middleware"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { getGitIdentities, createGitIdentity, @@ -271,7 +271,7 @@ export const useGitIdentitiesStore = create()( }), { name: "git-identities-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedProfileId: state.selectedProfileId, }), diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index 87aebe8c..05a5648b 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -7,7 +7,7 @@ import type { GitLogResponse, GitIdentitySummary, } from '@/lib/api/types'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -155,7 +155,7 @@ const GIT_BRANCH_CACHE_KEY = 'oc.gitBranchCache'; const readBranchCache = (): Record => { try { - const raw = getSafeStorage().getItem(GIT_BRANCH_CACHE_KEY); + const raw = getDeferredSafeStorage().getItem(GIT_BRANCH_CACHE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw) as Record; return parsed && typeof parsed === 'object' ? parsed : {}; @@ -169,7 +169,7 @@ const writeCachedBranches = (directory: string, branches: GitBranch): void => { try { const cache = readBranchCache(); cache[directory] = branches; - getSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache)); + getDeferredSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache)); } catch { // quota / serialization — ignore; live fetch still refreshes the store } diff --git a/packages/ui/src/stores/useInlineCommentDraftStore.ts b/packages/ui/src/stores/useInlineCommentDraftStore.ts index e6c48129..4148b3ab 100644 --- a/packages/ui/src/stores/useInlineCommentDraftStore.ts +++ b/packages/ui/src/stores/useInlineCommentDraftStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation'; @@ -207,7 +207,7 @@ export const useInlineCommentDraftStore = create()( }), { name: 'openchamber-inline-comment-drafts', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), version: 1, migrate: (persistedState: unknown) => { if (!persistedState || typeof persistedState !== 'object') { diff --git a/packages/ui/src/stores/useMcpConfigStore.ts b/packages/ui/src/stores/useMcpConfigStore.ts index 41a4be33..48a25a25 100644 --- a/packages/ui/src/stores/useMcpConfigStore.ts +++ b/packages/ui/src/stores/useMcpConfigStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import { startConfigUpdate, finishConfigUpdate, @@ -350,7 +350,7 @@ export const useMcpConfigStore = create()( }), { name: 'mcp-config-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedMcpName: state.selectedMcpName }), }, ), diff --git a/packages/ui/src/stores/usePluginsStore.ts b/packages/ui/src/stores/usePluginsStore.ts index 2a6aa5e2..364da000 100644 --- a/packages/ui/src/stores/usePluginsStore.ts +++ b/packages/ui/src/stores/usePluginsStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import { startConfigUpdate, finishConfigUpdate, @@ -352,7 +352,7 @@ export const usePluginsStore = create()( }), { name: 'plugins-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedId: state.selectedId }), }, ), diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index d5460f51..bb3f258f 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -6,7 +6,7 @@ import type { ProjectEntry } from '@/lib/api/types'; import type { DesktopSettings } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import { createProjectIdFromPath } from '@/lib/projectId'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; import { useDirectoryStore } from './useDirectoryStore'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { PROJECT_COLORS } from '@/lib/projectMeta'; @@ -64,7 +64,7 @@ interface ProjectsStore { getActiveProject: () => ProjectEntry | null; } -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); const PROJECTS_STORAGE_KEY = 'projects'; const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId'; diff --git a/packages/ui/src/stores/useSessionFoldersStore.test.ts b/packages/ui/src/stores/useSessionFoldersStore.test.ts index 42264081..95b8a9a4 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.test.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.test.ts @@ -22,6 +22,7 @@ const safeStorage = { } as Storage; mock.module('./utils/safeStorage', () => ({ + getDeferredSafeStorage: () => safeStorage, getSafeStorage: () => safeStorage, })); diff --git a/packages/ui/src/stores/useSessionFoldersStore.ts b/packages/ui/src/stores/useSessionFoldersStore.ts index 7b61d65f..c4254873 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; import { isVSCodeRuntime } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -46,7 +46,7 @@ const SESSION_FOLDERS_API_PATH = '/api/session-folders'; const DISK_WRITE_DEBOUNCE_MS = 250; const ARCHIVED_SCOPE_PREFIX = '__archived__:'; -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); let diskWriteTimer: ReturnType | null = null; let diskHydrated = false; let diskHydrationInFlight = false; diff --git a/packages/ui/src/stores/useSessionPinnedStore.ts b/packages/ui/src/stores/useSessionPinnedStore.ts index 1f60304b..56b42f71 100644 --- a/packages/ui/src/stores/useSessionPinnedStore.ts +++ b/packages/ui/src/stores/useSessionPinnedStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned'; @@ -29,7 +29,7 @@ type SessionPinnedStore = { toggle: (sessionId: string) => void; }; -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); export const useSessionPinnedStore = create((set, get) => ({ ids: readPinned(safeStorage), diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index f2e3dae0..f94c1f2a 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -1,13 +1,13 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage, } from "@/lib/configUpdate"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { runtimeFetch } from "@/lib/runtime-fetch"; import { opencodeClient } from '@/lib/opencode/client'; @@ -490,7 +490,7 @@ export const useSkillsStore = create()( }), { name: "skills-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedSkillName: state.selectedSkillName, }), diff --git a/packages/ui/src/stores/useTodosPersistStore.ts b/packages/ui/src/stores/useTodosPersistStore.ts index b9d394fb..0a5ba49f 100644 --- a/packages/ui/src/stores/useTodosPersistStore.ts +++ b/packages/ui/src/stores/useTodosPersistStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { createJSONStorage, devtools, persist } from 'zustand/middleware'; +import { devtools, persist } from 'zustand/middleware'; import type { Todo } from '@opencode-ai/sdk/v2/client'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; const MAX_SESSIONS = 50; @@ -55,7 +55,7 @@ export const useTodosPersistStore = create()( { name: 'openchamber-session-todos', version: 1, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ sessions: state.sessions }), }, ), diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index e4db27f1..b8abc30c 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; +import { devtools, persist } from 'zustand/middleware'; import type { SidebarSection } from '@/constants/sidebar'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography'; import type { ShortcutCombo } from '@/lib/shortcuts'; import type { DraftStarterRef } from '@/lib/draftStarters'; @@ -2106,7 +2106,7 @@ export const useUIStore = create()( }), { name: 'ui-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), version: 10, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { diff --git a/packages/ui/src/stores/utils/safeStorage.test.ts b/packages/ui/src/stores/utils/safeStorage.test.ts index 31301261..359d2ed1 100644 --- a/packages/ui/src/stores/utils/safeStorage.test.ts +++ b/packages/ui/src/stores/utils/safeStorage.test.ts @@ -4,6 +4,24 @@ const importSafeStorage = async () => { return await import(`./safeStorage.ts?test=${Date.now()}-${Math.random()}`) as typeof import('./safeStorage'); }; +const createFakeStorage = (): Storage => { + const store = new Map(); + return { + getItem: (k) => (store.has(k) ? store.get(k)! : null), + setItem: (k, v) => { + store.set(k, String(v)); + }, + removeItem: (k) => { + store.delete(k); + }, + clear: () => store.clear(), + key: (i) => Array.from(store.keys())[i] ?? null, + get length() { + return store.size; + }, + } as Storage; +}; + describe('safeStorage', () => { test('falls back to memory when storage getters throw', async () => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); @@ -45,4 +63,99 @@ describe('safeStorage', () => { } } }); + + test('defers persisted JSON serialization and serves pending reads', async () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const previousStringify = JSON.stringify; + const stringifyCalls: unknown[] = []; + const backingStorage = createFakeStorage(); + const fakeWindow = { + localStorage: backingStorage, + sessionStorage: createFakeStorage(), + addEventListener: () => {}, + }; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: fakeWindow, + }); + + try { + JSON.stringify = ((value: unknown, replacer?: Parameters[1], space?: Parameters[2]) => { + stringifyCalls.push(value); + return previousStringify(value, replacer, space); + }) as typeof JSON.stringify; + + const { createDeferredSafeJSONStorage } = await importSafeStorage(); + const storage = createDeferredSafeJSONStorage<{ value: string }>(); + + expect(Boolean(storage)).toBe(true); + if (!storage) throw new Error('storage unavailable'); + + storage.setItem('k', { state: { value: 'v' } }); + + // Neither serialization nor the backing write runs on the call site... + expect(stringifyCalls).toHaveLength(0); + expect(backingStorage.getItem('k')).toBeNull(); + // ...but read-after-write still returns the pending value. + expect(storage.getItem('k')).toEqual({ state: { value: 'v' } }); + + // Coalesce: a second write to the same key should not produce two + // stringifications/backing writes, and the latest value wins. + storage.setItem('k', { state: { value: 'v2' } }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(stringifyCalls).toEqual([{ state: { value: 'v2' } }]); + expect(backingStorage.getItem('k')).toBe('{"state":{"value":"v2"}}'); + expect(storage.getItem('k')).toEqual({ state: { value: 'v2' } }); + } finally { + JSON.stringify = previousStringify; + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } + }); + + test('defers direct storage writes and flushes on pagehide', async () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const backingStorage = createFakeStorage(); + const listeners = new Map void>>(); + const fakeWindow = { + localStorage: backingStorage, + sessionStorage: createFakeStorage(), + addEventListener: (event: string, listener: () => void) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + }, + }; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: fakeWindow, + }); + + try { + const { getDeferredSafeStorage } = await importSafeStorage(); + const storage = getDeferredSafeStorage(); + + storage.setItem('direct-k', 'direct-v'); + + expect(backingStorage.getItem('direct-k')).toBeNull(); + expect(storage.getItem('direct-k')).toBe('direct-v'); + + for (const listener of listeners.get('pagehide') ?? []) { + listener(); + } + + expect(backingStorage.getItem('direct-k')).toBe('direct-v'); + } finally { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } + }); }); diff --git a/packages/ui/src/stores/utils/safeStorage.ts b/packages/ui/src/stores/utils/safeStorage.ts index 3a568aa8..8389d089 100644 --- a/packages/ui/src/stores/utils/safeStorage.ts +++ b/packages/ui/src/stores/utils/safeStorage.ts @@ -1,5 +1,191 @@ +import type { PersistStorage, StateStorage, StorageValue } from 'zustand/middleware'; + let safeStorageInstance: Storage | null = null; let safeSessionStorageInstance: Storage | null = null; +let deferredSafeStorageInstance: Storage | null = null; + +const deferredFlushers = new Set<() => void>(); +let deferredFlushListenersRegistered = false; + +type JsonStorageOptions = { + reviver?: (key: string, value: unknown) => unknown; + replacer?: (key: string, value: unknown) => unknown; +}; + +const registerDeferredFlusher = (flush: () => void) => { + deferredFlushers.add(flush); + if (deferredFlushListenersRegistered || typeof window === 'undefined') return; + + deferredFlushListenersRegistered = true; + const flushAll = () => { + for (const flushDeferredStorage of deferredFlushers) { + flushDeferredStorage(); + } + }; + + try { + window.addEventListener('pagehide', flushAll, { capture: true }); + window.addEventListener('beforeunload', flushAll, { capture: true }); + window.addEventListener('visibilitychange', () => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') flushAll(); + }); + window.addEventListener('freeze', flushAll); + } catch { + // Restricted environments can reject listeners; timers still flush. + } +}; + +const createDeferredJSONStorage = ( + getStorage: () => StateStorage, + options?: JsonStorageOptions, +): PersistStorage | undefined => { + let storage: StateStorage; + try { + storage = getStorage(); + } catch { + return undefined; + } + + const pendingWrites = new Map>(); + const pendingDeletes = new Set(); + let flushTimer: ReturnType | undefined; + + const flush = () => { + flushTimer = undefined; + if (pendingWrites.size === 0 && pendingDeletes.size === 0) return; + + const writes = Array.from(pendingWrites.entries()); + const deletes = Array.from(pendingDeletes); + pendingWrites.clear(); + pendingDeletes.clear(); + + for (const [name, value] of writes) { + try { + storage.setItem(name, JSON.stringify(value, options?.replacer)); + } catch (error) { + console.error('Failed to persist deferred storage value', error); + } + } + for (const name of deletes) { + try { + storage.removeItem(name); + } catch (error) { + console.error('Failed to remove deferred storage value', error); + } + } + }; + + const scheduleFlush = () => { + if (flushTimer !== undefined) return; + flushTimer = setTimeout(flush, 0); + }; + + registerDeferredFlusher(flush); + + return { + getItem: (name) => { + if (pendingWrites.has(name)) { + return pendingWrites.get(name) ?? null; + } + if (pendingDeletes.has(name)) { + return null; + } + + const parse = (value: string | null): StorageValue | null => { + if (value === null) return null; + return JSON.parse(value, options?.reviver) as StorageValue; + }; + const value = storage.getItem(name); + if (value instanceof Promise) { + return value.then(parse); + } + return parse(value); + }, + setItem: (name, value) => { + pendingWrites.set(name, value); + pendingDeletes.delete(name); + scheduleFlush(); + }, + removeItem: (name) => { + pendingWrites.delete(name); + pendingDeletes.add(name); + scheduleFlush(); + }, + }; +}; + +export const createDeferredSafeJSONStorage = (options?: JsonStorageOptions) => ( + createDeferredJSONStorage(() => getSafeStorage(), options) +); + +const createDeferredStorage = (storage: Storage): Storage => { + const pendingWrites = new Map(); + const pendingDeletes = new Set(); + let flushTimer: ReturnType | undefined; + + const flush = () => { + flushTimer = undefined; + if (pendingWrites.size === 0 && pendingDeletes.size === 0) return; + + const writes = Array.from(pendingWrites.entries()); + const deletes = Array.from(pendingDeletes); + pendingWrites.clear(); + pendingDeletes.clear(); + + for (const [key, value] of writes) { + try { + storage.setItem(key, value); + } catch (error) { + console.error('Failed to persist deferred storage value', error); + } + } + for (const key of deletes) { + try { + storage.removeItem(key); + } catch (error) { + console.error('Failed to remove deferred storage value', error); + } + } + }; + + const scheduleFlush = () => { + if (flushTimer !== undefined) return; + flushTimer = setTimeout(flush, 0); + }; + + registerDeferredFlusher(flush); + + return { + getItem: (key) => { + if (pendingWrites.has(key)) return pendingWrites.get(key) ?? null; + if (pendingDeletes.has(key)) return null; + return storage.getItem(key); + }, + setItem: (key, value) => { + pendingWrites.set(key, value); + pendingDeletes.delete(key); + scheduleFlush(); + }, + removeItem: (key) => { + pendingWrites.delete(key); + pendingDeletes.add(key); + scheduleFlush(); + }, + clear: () => { + pendingWrites.clear(); + pendingDeletes.clear(); + if (flushTimer !== undefined) { + clearTimeout(flushTimer); + flushTimer = undefined; + } + storage.clear(); + }, + key: (index) => storage.key(index), + get length() { + return storage.length; + }, + } as Storage; +}; const getWindowStorage = (key: 'localStorage' | 'sessionStorage'): Storage | null => { if (typeof window === 'undefined') { @@ -135,6 +321,13 @@ export const getSafeStorage = (): Storage => { return safeStorageInstance; }; +export const getDeferredSafeStorage = (): Storage => { + if (!deferredSafeStorageInstance) { + deferredSafeStorageInstance = createDeferredStorage(getSafeStorage()); + } + return deferredSafeStorageInstance; +}; + const createSafeSessionStorage = (): Storage => { const baseStorage = getWindowStorage('sessionStorage'); diff --git a/packages/ui/src/sync/selection-store.ts b/packages/ui/src/sync/selection-store.ts index 7b3058e3..a8c1cb21 100644 --- a/packages/ui/src/sync/selection-store.ts +++ b/packages/ui/src/sync/selection-store.ts @@ -4,8 +4,8 @@ */ import { create } from "zustand" -import { persist, createJSONStorage } from "zustand/middleware" -import { getSafeStorage } from "@/stores/utils/safeStorage" +import { persist } from "zustand/middleware" +import { createDeferredSafeJSONStorage } from "@/stores/utils/safeStorage" type ModelSelection = { providerId: string; modelId: string } type LastUsedProvider = { providerID: string; modelID: string } @@ -126,7 +126,7 @@ export const useSelectionStore = create()( { name: "selection-store", version: 1, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => { // Convert Maps to arrays and slice to keep only the most recent MAX_PERSISTED_SESSIONS const models = Array.from(state.sessionModelSelections.entries()).slice(-MAX_PERSISTED_SESSIONS) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index dabad03c..9b09c711 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -25,7 +25,7 @@ import { useDirectoryStore } from "@/stores/useDirectoryStore" import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore" import { useCommandsStore } from "@/stores/useCommandsStore" import { useSkillsStore } from "@/stores/useSkillsStore" -import { getSafeStorage } from "@/stores/utils/safeStorage" +import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" @@ -327,7 +327,7 @@ const resolveDirectoryKey = (session: Session): string | null => { ?? normalizePath(sessionRecord.project?.worktree ?? null) } -const safeStorage = getSafeStorage() +const safeStorage = getDeferredSafeStorage() const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget" type PersistedDraftTarget = { projectId: string | null; directory: string | null } @@ -515,7 +515,7 @@ const WORKTREE_MAP_STORAGE_KEY = 'oc.worktreeMap' const loadPersistedWorktreeMap = (): Map => { try { - const raw = getSafeStorage().getItem(WORKTREE_MAP_STORAGE_KEY) + const raw = getDeferredSafeStorage().getItem(WORKTREE_MAP_STORAGE_KEY) if (!raw) return new Map() const entries = JSON.parse(raw) as Array<[string, WorktreeMetadata[]]> if (!Array.isArray(entries)) return new Map() @@ -529,7 +529,7 @@ const loadPersistedWorktreeMap = (): Map => { const persistWorktreeMap = (map: Map): void => { try { - getSafeStorage().setItem(WORKTREE_MAP_STORAGE_KEY, JSON.stringify([...map.entries()])) + getDeferredSafeStorage().setItem(WORKTREE_MAP_STORAGE_KEY, JSON.stringify([...map.entries()])) } catch { // quota / serialization error — ignore; discovery still refreshes at runtime }