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 <artmore@protonmail.com>
This commit is contained in:
Tom Rochette
2026-06-30 11:47:52 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 088a70fe5a
commit 1505274f94
32 changed files with 406 additions and 83 deletions
@@ -13,7 +13,7 @@ import { useSync } from '@/sync/use-sync';
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore'; 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 { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { NewWorktreeDialog } from './NewWorktreeDialog'; import { NewWorktreeDialog } from './NewWorktreeDialog';
@@ -188,7 +188,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [editTitle, setEditTitle] = React.useState(''); const [editTitle, setEditTitle] = React.useState('');
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null); const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null);
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set()); const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
const safeStorage = React.useMemo(() => getSafeStorage(), []); const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []);
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set()); const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map()); const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
@@ -207,7 +207,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle); const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => { const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
try { try {
const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY); const raw = getDeferredSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY);
if (!raw) { if (!raw) {
return new Set(); return new Set();
} }
@@ -219,7 +219,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}); });
const [groupOrderByProject, setGroupOrderByProject] = React.useState<Map<string, string[]>>(() => { const [groupOrderByProject, setGroupOrderByProject] = React.useState<Map<string, string[]>>(() => {
try { try {
const raw = getSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY); const raw = getDeferredSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY);
if (!raw) { if (!raw) {
return new Map(); return new Map();
} }
@@ -237,7 +237,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}); });
const [activeSessionByProject, setActiveSessionByProject] = React.useState<Map<string, string>>(() => { const [activeSessionByProject, setActiveSessionByProject] = React.useState<Map<string, string>>(() => {
try { try {
const raw = getSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY); const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY);
if (!raw) { if (!raw) {
return new Map(); return new Map();
} }
@@ -6,7 +6,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { getSafeStorage } from '@/stores/utils/safeStorage'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { import {
resolveOpenCodeUpdateVersion, resolveOpenCodeUpdateVersion,
resolveOpenCodeUpgradeStatusVersion, resolveOpenCodeUpgradeStatusVersion,
@@ -100,7 +100,7 @@ export const OpenCodeUpdateToast: React.FC = () => {
} }
const decision = shouldShowOpenCodeUpdateToast({ const decision = shouldShowOpenCodeUpdateToast({
version, version,
dismissedVersion: getSafeStorage().getItem(UPDATE_TOAST_DISMISSED_VERSION_KEY), dismissedVersion: getDeferredSafeStorage().getItem(UPDATE_TOAST_DISMISSED_VERSION_KEY),
seenVersions: seenVersionsRef.current, seenVersions: seenVersionsRef.current,
}); });
if (!decision) { if (!decision) {
@@ -119,7 +119,7 @@ export const OpenCodeUpdateToast: React.FC = () => {
cancel: { cancel: {
label: t('opencodeUpdate.toast.actions.dismiss'), label: t('opencodeUpdate.toast.actions.dismiss'),
onClick: () => { onClick: () => {
getSafeStorage().setItem(UPDATE_TOAST_DISMISSED_VERSION_KEY, version); getDeferredSafeStorage().setItem(UPDATE_TOAST_DISMISSED_VERSION_KEY, version);
void updateDesktopSettings({ openCodeUpdateToastDismissedVersion: version }); void updateDesktopSettings({ openCodeUpdateToastDismissedVersion: version });
toast.dismiss(UPDATE_TOAST_ID); toast.dismiss(UPDATE_TOAST_ID);
}, },
+3 -3
View File
@@ -3,7 +3,7 @@ import { toast } from '@/components/ui';
import { isWebRuntime } from '@/lib/desktop'; import { isWebRuntime } from '@/lib/desktop';
import { usePwaDetection } from '@/hooks/usePwaDetection'; import { usePwaDetection } from '@/hooks/usePwaDetection';
import { useI18n } from '@/lib/i18n'; 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'; import { shouldShowPwaInstallToast } from '@/components/update/openCodeUpdateDedup';
type InstallPromptOutcome = 'accepted' | 'dismissed'; type InstallPromptOutcome = 'accepted' | 'dismissed';
@@ -66,7 +66,7 @@ export const usePwaInstallPrompt = () => {
installEvent.preventDefault(); installEvent.preventDefault();
deferredPrompt = installEvent; deferredPrompt = installEvent;
const localStorage = getSafeStorage(); const localStorage = getDeferredSafeStorage();
const sessionStorage = getSafeSessionStorage(); const sessionStorage = getSafeSessionStorage();
const decision = shouldShowPwaInstallToast({ const decision = shouldShowPwaInstallToast({
dismissed: localStorage.getItem(INSTALL_TOAST_DISMISSED_KEY), dismissed: localStorage.getItem(INSTALL_TOAST_DISMISSED_KEY),
@@ -90,7 +90,7 @@ export const usePwaInstallPrompt = () => {
cancel: { cancel: {
label: tRef.current('pwa.installPrompt.dismiss'), label: tRef.current('pwa.installPrompt.dismiss'),
onClick: () => { onClick: () => {
getSafeStorage().setItem(INSTALL_TOAST_DISMISSED_KEY, 'true'); getDeferredSafeStorage().setItem(INSTALL_TOAST_DISMISSED_KEY, 'true');
dismissInstallToast(); dismissInstallToast();
}, },
}, },
+3 -3
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { getSafeStorage } from '@/stores/utils/safeStorage'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden'; const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden';
@@ -10,7 +10,7 @@ const readStoredShowHidden = (): boolean => {
return true; return true;
} }
try { try {
const stored = getSafeStorage().getItem(SHOW_HIDDEN_STORAGE_KEY); const stored = getDeferredSafeStorage().getItem(SHOW_HIDDEN_STORAGE_KEY);
if (stored === null) { if (stored === null) {
return true; return true;
} }
@@ -35,7 +35,7 @@ export const setDirectoryShowHidden = (
return; return;
} }
try { try {
getSafeStorage().setItem(SHOW_HIDDEN_STORAGE_KEY, value ? 'true' : 'false'); getDeferredSafeStorage().setItem(SHOW_HIDDEN_STORAGE_KEY, value ? 'true' : 'false');
notifyDirectoryShowHiddenChanged(); notifyDirectoryShowHiddenChanged();
} catch { } catch {
// ignore storage errors // ignore storage errors
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { getSafeStorage } from '@/stores/utils/safeStorage'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
const SHOW_GITIGNORED_STORAGE_KEY = 'filesViewShowGitignored'; const SHOW_GITIGNORED_STORAGE_KEY = 'filesViewShowGitignored';
@@ -11,7 +11,7 @@ const readStoredShowGitignored = (): boolean => {
return false; return false;
} }
try { try {
const stored = getSafeStorage().getItem(SHOW_GITIGNORED_STORAGE_KEY); const stored = getDeferredSafeStorage().getItem(SHOW_GITIGNORED_STORAGE_KEY);
return stored === 'true'; return stored === 'true';
} catch { } catch {
return false; return false;
@@ -33,7 +33,7 @@ export const setFilesViewShowGitignored = (
return; return;
} }
try { try {
getSafeStorage().setItem(SHOW_GITIGNORED_STORAGE_KEY, value ? 'true' : 'false'); getDeferredSafeStorage().setItem(SHOW_GITIGNORED_STORAGE_KEY, value ? 'true' : 'false');
notifyFilesViewShowGitignoredChanged(); notifyFilesViewShowGitignoredChanged();
} catch { } catch {
// ignore storage errors // ignore storage errors
+3 -3
View File
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { create } from "zustand"; import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware"; import { devtools, persist } from "zustand/middleware";
import type { EditPermissionMode } from "./types/sessionTypes"; import type { EditPermissionMode } from "./types/sessionTypes";
import { getAgentDefaultEditPermission } from "./utils/permissionUtils"; import { getAgentDefaultEditPermission } from "./utils/permissionUtils";
import { extractTokensFromMessage } from "./utils/tokenUtils"; import { extractTokensFromMessage } from "./utils/tokenUtils";
import { calculateContextUsage } from "./utils/contextUtils"; import { calculateContextUsage } from "./utils/contextUtils";
import { getSafeStorage } from "./utils/safeStorage"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
interface ContextUsage { interface ContextUsage {
totalTokens: number; totalTokens: number;
@@ -449,7 +449,7 @@ export const useContextStore = create<ContextStore>()(
}), }),
{ {
name: "context-store", name: "context-store",
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ partialize: (state) => ({
sessionModelSelections: Array.from(state.sessionModelSelections.entries()), sessionModelSelections: Array.from(state.sessionModelSelections.entries()),
sessionAgentSelections: Array.from(state.sessionAgentSelections.entries()), sessionAgentSelections: Array.from(state.sessionAgentSelections.entries()),
+3 -3
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage'; import { createDeferredSafeJSONStorage } from './utils/safeStorage';
import type { AttachedFile } from './types/sessionTypes'; import type { AttachedFile } from './types/sessionTypes';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
@@ -201,7 +201,7 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
{ {
name: 'message-queue-store', name: 'message-queue-store',
version: 1, version: 1,
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ partialize: (state) => ({
queuedMessages: state.queuedMessages, queuedMessages: state.queuedMessages,
followUpBehavior: state.followUpBehavior, followUpBehavior: state.followUpBehavior,
+3 -3
View File
@@ -1,11 +1,11 @@
import { create } from "zustand"; 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 type { Session } from "@opencode-ai/sdk/v2/client";
import { import {
autoRespondsPermission, autoRespondsPermission,
type PermissionAutoAcceptMap, type PermissionAutoAcceptMap,
} from "./utils/permissionAutoAccept"; } from "./utils/permissionAutoAccept";
import { getSafeStorage } from "./utils/safeStorage"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { getAllSyncSessions, getSyncChildStores } from "@/sync/sync-refs"; import { getAllSyncSessions, getSyncChildStores } from "@/sync/sync-refs";
import { opencodeClient } from "@/lib/opencode/client"; import { opencodeClient } from "@/lib/opencode/client";
import { respondToPermission } from "@/sync/session-actions"; import { respondToPermission } from "@/sync/session-actions";
@@ -201,7 +201,7 @@ const autoRespondsPermissionBySession = (
}); });
}; };
const getStorage = () => createJSONStorage(() => getSafeStorage()); const getStorage = () => createDeferredSafeJSONStorage();
export const usePermissionStore = create<PermissionStore>()( export const usePermissionStore = create<PermissionStore>()(
devtools( devtools(
+3 -3
View File
@@ -1,6 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import type { StoreApi, UseBoundStore } 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 type { Agent, PermissionConfig } from "@opencode-ai/sdk/v2";
import { opencodeClient } from "@/lib/opencode/client"; import { opencodeClient } from "@/lib/opencode/client";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges, type ConfigChangeScope } from "@/lib/configSync"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges, type ConfigChangeScope } from "@/lib/configSync";
@@ -9,7 +9,7 @@ import {
finishConfigUpdate, finishConfigUpdate,
updateConfigUpdateMessage, updateConfigUpdateMessage,
} from "@/lib/configUpdate"; } from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { useConfigStore } from "@/stores/useConfigStore"; import { useConfigStore } from "@/stores/useConfigStore";
import { invalidateCommandsLoadCache, useCommandsStore } from "@/stores/useCommandsStore"; import { invalidateCommandsLoadCache, useCommandsStore } from "@/stores/useCommandsStore";
import { useProjectsStore } from "@/stores/useProjectsStore"; import { useProjectsStore } from "@/stores/useProjectsStore";
@@ -556,7 +556,7 @@ export const useAgentsStore = create<AgentsStore>()(
}), }),
{ {
name: "agents-store", name: "agents-store",
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ partialize: (state) => ({
selectedAgentName: state.selectedAgentName, selectedAgentName: state.selectedAgentName,
}), }),
+3 -3
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { getSafeStorage } from '@/stores/utils/safeStorage'; import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage';
type AutoReviewPhase = 'waiting_for_reviewer' | 'waiting_for_implementer'; type AutoReviewPhase = 'waiting_for_reviewer' | 'waiting_for_implementer';
type AutoReviewStatus = 'running' | 'completed' | 'stopped' | 'error'; type AutoReviewStatus = 'running' | 'completed' | 'stopped' | 'error';
@@ -88,7 +88,7 @@ export const useAutoReviewStore = create<AutoReviewState>()(
}), }),
{ {
name: 'auto-review-store', name: 'auto-review-store',
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ runsByOriginalSessionID: state.runsByOriginalSessionID }), partialize: (state) => ({ runsByOriginalSessionID: state.runsByOriginalSessionID }),
}, },
), ),
+3 -3
View File
@@ -1,6 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import type { StoreApi, UseBoundStore } 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 { opencodeClient } from "@/lib/opencode/client";
import { import {
startConfigUpdate, startConfigUpdate,
@@ -8,7 +8,7 @@ import {
updateConfigUpdateMessage, updateConfigUpdateMessage,
} from "@/lib/configUpdate"; } from "@/lib/configUpdate";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import { getSafeStorage } from "./utils/safeStorage"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { useProjectsStore } from "@/stores/useProjectsStore"; import { useProjectsStore } from "@/stores/useProjectsStore";
import { runtimeFetch } from "@/lib/runtime-fetch"; import { runtimeFetch } from "@/lib/runtime-fetch";
@@ -427,7 +427,7 @@ export const useCommandsStore = create<CommandsStore>()(
}), }),
{ {
name: "commands-store", name: "commands-store",
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ partialize: (state) => ({
selectedCommandName: state.selectedCommandName, selectedCommandName: state.selectedCommandName,
}), }),
@@ -129,7 +129,23 @@ const deferred = <T,>() => {
}; };
mock.module('@/stores/utils/safeStorage', () => ({ mock.module('@/stores/utils/safeStorage', () => ({
getDeferredSafeStorage: () => makeStorage(),
getSafeStorage: () => 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', () => ({ mock.module('@/stores/useProjectsStore', () => ({
+3 -3
View File
@@ -1,11 +1,11 @@
import { create } from "zustand"; import { create } from "zustand";
import type { StoreApi, UseBoundStore } 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 type { Provider, Agent, Config } from "@opencode-ai/sdk/v2";
import { opencodeClient } from "@/lib/opencode/client"; import { opencodeClient } from "@/lib/opencode/client";
import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import type { ModelMetadata } from "@/types"; import type { ModelMetadata } from "@/types";
import { getSafeStorage } from "./utils/safeStorage"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { filterVisibleAgents } from "./useAgentsStore"; import { filterVisibleAgents } from "./useAgentsStore";
import { isPrimaryMode } from "@/components/chat/mobileControlsUtils"; import { isPrimaryMode } from "@/components/chat/mobileControlsUtils";
import { useSessionUIStore } from "@/sync/session-ui-store"; import { useSessionUIStore } from "@/sync/session-ui-store";
@@ -3283,7 +3283,7 @@ export const useConfigStore = create<ConfigStore>()(
}), }),
{ {
name: "config-store", name: "config-store",
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
merge: (persistedState, currentState) => merge: (persistedState, currentState) =>
hydrateActiveDirectorySnapshot({ hydrateActiveDirectorySnapshot({
...currentState, ...currentState,
+2 -2
View File
@@ -6,7 +6,7 @@ import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { getSafeStorage } from './utils/safeStorage'; import { getDeferredSafeStorage } from './utils/safeStorage';
interface DirectoryStore { interface DirectoryStore {
@@ -28,7 +28,7 @@ interface DirectoryStore {
let cachedHomeDirectory: string | null = null; let cachedHomeDirectory: string | null = null;
let homeResolveGeneration = 0; let homeResolveGeneration = 0;
const safeStorage = getSafeStorage(); const safeStorage = getDeferredSafeStorage();
const persistedLastDirectory = safeStorage.getItem('lastDirectory'); const persistedLastDirectory = safeStorage.getItem('lastDirectory');
const initialHasPersistedDirectory = const initialHasPersistedDirectory =
typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0; typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0;
@@ -1,7 +1,7 @@
import { create } from 'zustand'; 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 = { type RootTabsState = {
openPaths: string[]; openPaths: string[];
@@ -451,7 +451,7 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
{ {
name: 'files-view-tabs-store', name: 'files-view-tabs-store',
version: 2, version: 2,
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
migrate: (persistedState) => { migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') { if (!persistedState || typeof persistedState !== 'object') {
return { byRoot: {} }; return { byRoot: {} };
@@ -1,8 +1,8 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types'; import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types';
import { mapWithConcurrency } from '@/lib/concurrency'; 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_TTL_MS = 90_000;
const PR_REVALIDATE_INTERVAL_MS = 15_000; const PR_REVALIDATE_INTERVAL_MS = 15_000;
@@ -647,7 +647,7 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
}), }),
{ {
name: PR_STATUS_STORAGE_KEY, name: PR_STATUS_STORAGE_KEY,
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ partialize: (state) => ({
entries: Object.fromEntries( entries: Object.fromEntries(
Object.entries(state.entries) Object.entries(state.entries)
@@ -1,7 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware"; import { devtools, persist } from "zustand/middleware";
import { getSafeStorage } from "./utils/safeStorage"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { import {
getGitIdentities, getGitIdentities,
createGitIdentity, createGitIdentity,
@@ -271,7 +271,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
}), }),
{ {
name: "git-identities-store", name: "git-identities-store",
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ partialize: (state) => ({
selectedProfileId: state.selectedProfileId, selectedProfileId: state.selectedProfileId,
}), }),
+3 -3
View File
@@ -7,7 +7,7 @@ import type {
GitLogResponse, GitLogResponse,
GitIdentitySummary, GitIdentitySummary,
} from '@/lib/api/types'; } from '@/lib/api/types';
import { getSafeStorage } from '@/stores/utils/safeStorage'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
const LOG_STALE_THRESHOLD = 10000; const LOG_STALE_THRESHOLD = 10000;
const REPO_CHECK_STALE_THRESHOLD = 60_000; const REPO_CHECK_STALE_THRESHOLD = 60_000;
@@ -155,7 +155,7 @@ const GIT_BRANCH_CACHE_KEY = 'oc.gitBranchCache';
const readBranchCache = (): Record<string, GitBranch> => { const readBranchCache = (): Record<string, GitBranch> => {
try { try {
const raw = getSafeStorage().getItem(GIT_BRANCH_CACHE_KEY); const raw = getDeferredSafeStorage().getItem(GIT_BRANCH_CACHE_KEY);
if (!raw) return {}; if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, GitBranch>; const parsed = JSON.parse(raw) as Record<string, GitBranch>;
return parsed && typeof parsed === 'object' ? parsed : {}; return parsed && typeof parsed === 'object' ? parsed : {};
@@ -169,7 +169,7 @@ const writeCachedBranches = (directory: string, branches: GitBranch): void => {
try { try {
const cache = readBranchCache(); const cache = readBranchCache();
cache[directory] = branches; cache[directory] = branches;
getSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache)); getDeferredSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache));
} catch { } catch {
// quota / serialization — ignore; live fetch still refreshes the store // quota / serialization — ignore; live fetch still refreshes the store
} }
@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage'; import { createDeferredSafeJSONStorage } from './utils/safeStorage';
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation'; export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation';
@@ -207,7 +207,7 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
}), }),
{ {
name: 'openchamber-inline-comment-drafts', name: 'openchamber-inline-comment-drafts',
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
version: 1, version: 1,
migrate: (persistedState: unknown) => { migrate: (persistedState: unknown) => {
if (!persistedState || typeof persistedState !== 'object') { if (!persistedState || typeof persistedState !== 'object') {
+3 -3
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage'; import { createDeferredSafeJSONStorage } from './utils/safeStorage';
import { import {
startConfigUpdate, startConfigUpdate,
finishConfigUpdate, finishConfigUpdate,
@@ -350,7 +350,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}), }),
{ {
name: 'mcp-config-store', name: 'mcp-config-store',
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ selectedMcpName: state.selectedMcpName }), partialize: (state) => ({ selectedMcpName: state.selectedMcpName }),
}, },
), ),
+3 -3
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage'; import { createDeferredSafeJSONStorage } from './utils/safeStorage';
import { import {
startConfigUpdate, startConfigUpdate,
finishConfigUpdate, finishConfigUpdate,
@@ -352,7 +352,7 @@ export const usePluginsStore = create<PluginsStore>()(
}), }),
{ {
name: 'plugins-store', name: 'plugins-store',
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ selectedId: state.selectedId }), partialize: (state) => ({ selectedId: state.selectedId }),
}, },
), ),
+2 -2
View File
@@ -6,7 +6,7 @@ import type { ProjectEntry } from '@/lib/api/types';
import type { DesktopSettings } from '@/lib/desktop'; import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { createProjectIdFromPath } from '@/lib/projectId'; import { createProjectIdFromPath } from '@/lib/projectId';
import { getSafeStorage } from './utils/safeStorage'; import { getDeferredSafeStorage } from './utils/safeStorage';
import { useDirectoryStore } from './useDirectoryStore'; import { useDirectoryStore } from './useDirectoryStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { PROJECT_COLORS } from '@/lib/projectMeta'; import { PROJECT_COLORS } from '@/lib/projectMeta';
@@ -64,7 +64,7 @@ interface ProjectsStore {
getActiveProject: () => ProjectEntry | null; getActiveProject: () => ProjectEntry | null;
} }
const safeStorage = getSafeStorage(); const safeStorage = getDeferredSafeStorage();
const PROJECTS_STORAGE_KEY = 'projects'; const PROJECTS_STORAGE_KEY = 'projects';
const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId'; const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId';
@@ -22,6 +22,7 @@ const safeStorage = {
} as Storage; } as Storage;
mock.module('./utils/safeStorage', () => ({ mock.module('./utils/safeStorage', () => ({
getDeferredSafeStorage: () => safeStorage,
getSafeStorage: () => safeStorage, getSafeStorage: () => safeStorage,
})); }));
@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools } from 'zustand/middleware'; import { devtools } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage'; import { getDeferredSafeStorage } from './utils/safeStorage';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch'; 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 DISK_WRITE_DEBOUNCE_MS = 250;
const ARCHIVED_SCOPE_PREFIX = '__archived__:'; const ARCHIVED_SCOPE_PREFIX = '__archived__:';
const safeStorage = getSafeStorage(); const safeStorage = getDeferredSafeStorage();
let diskWriteTimer: ReturnType<typeof setTimeout> | null = null; let diskWriteTimer: ReturnType<typeof setTimeout> | null = null;
let diskHydrated = false; let diskHydrated = false;
let diskHydrationInFlight = false; let diskHydrationInFlight = false;
@@ -1,5 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { getSafeStorage } from './utils/safeStorage'; import { getDeferredSafeStorage } from './utils/safeStorage';
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned'; const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
@@ -29,7 +29,7 @@ type SessionPinnedStore = {
toggle: (sessionId: string) => void; toggle: (sessionId: string) => void;
}; };
const safeStorage = getSafeStorage(); const safeStorage = getDeferredSafeStorage();
export const useSessionPinnedStore = create<SessionPinnedStore>((set, get) => ({ export const useSessionPinnedStore = create<SessionPinnedStore>((set, get) => ({
ids: readPinned(safeStorage), ids: readPinned(safeStorage),
+3 -3
View File
@@ -1,13 +1,13 @@
import { create } from "zustand"; import { create } from "zustand";
import type { StoreApi, UseBoundStore } 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 { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import { import {
startConfigUpdate, startConfigUpdate,
finishConfigUpdate, finishConfigUpdate,
updateConfigUpdateMessage, updateConfigUpdateMessage,
} from "@/lib/configUpdate"; } from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { runtimeFetch } from "@/lib/runtime-fetch"; import { runtimeFetch } from "@/lib/runtime-fetch";
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
@@ -490,7 +490,7 @@ export const useSkillsStore = create<SkillsStore>()(
}), }),
{ {
name: "skills-store", name: "skills-store",
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ partialize: (state) => ({
selectedSkillName: state.selectedSkillName, selectedSkillName: state.selectedSkillName,
}), }),
@@ -1,7 +1,7 @@
import { create } from 'zustand'; 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 type { Todo } from '@opencode-ai/sdk/v2/client';
import { getSafeStorage } from './utils/safeStorage'; import { createDeferredSafeJSONStorage } from './utils/safeStorage';
const MAX_SESSIONS = 50; const MAX_SESSIONS = 50;
@@ -55,7 +55,7 @@ export const useTodosPersistStore = create<TodosPersistState>()(
{ {
name: 'openchamber-session-todos', name: 'openchamber-session-todos',
version: 1, version: 1,
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ sessions: state.sessions }), partialize: (state) => ({ sessions: state.sessions }),
}, },
), ),
+3 -3
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import type { SidebarSection } from '@/constants/sidebar'; 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 { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
import type { ShortcutCombo } from '@/lib/shortcuts'; import type { ShortcutCombo } from '@/lib/shortcuts';
import type { DraftStarterRef } from '@/lib/draftStarters'; import type { DraftStarterRef } from '@/lib/draftStarters';
@@ -2106,7 +2106,7 @@ export const useUIStore = create<UIStore>()(
}), }),
{ {
name: 'ui-store', name: 'ui-store',
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
version: 10, version: 10,
migrate: (persistedState, version) => { migrate: (persistedState, version) => {
if (!persistedState || typeof persistedState !== 'object') { if (!persistedState || typeof persistedState !== 'object') {
@@ -4,6 +4,24 @@ const importSafeStorage = async () => {
return await import(`./safeStorage.ts?test=${Date.now()}-${Math.random()}`) as typeof import('./safeStorage'); return await import(`./safeStorage.ts?test=${Date.now()}-${Math.random()}`) as typeof import('./safeStorage');
}; };
const createFakeStorage = (): Storage => {
const store = new Map<string, string>();
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', () => { describe('safeStorage', () => {
test('falls back to memory when storage getters throw', async () => { test('falls back to memory when storage getters throw', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); 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<typeof JSON.stringify>[1], space?: Parameters<typeof JSON.stringify>[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<string, Array<() => 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;
}
}
});
}); });
+193
View File
@@ -1,5 +1,191 @@
import type { PersistStorage, StateStorage, StorageValue } from 'zustand/middleware';
let safeStorageInstance: Storage | null = null; let safeStorageInstance: Storage | null = null;
let safeSessionStorageInstance: 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 = <S>(
getStorage: () => StateStorage,
options?: JsonStorageOptions,
): PersistStorage<S> | undefined => {
let storage: StateStorage;
try {
storage = getStorage();
} catch {
return undefined;
}
const pendingWrites = new Map<string, StorageValue<S>>();
const pendingDeletes = new Set<string>();
let flushTimer: ReturnType<typeof setTimeout> | 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<S> | null => {
if (value === null) return null;
return JSON.parse(value, options?.reviver) as StorageValue<S>;
};
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 = <S>(options?: JsonStorageOptions) => (
createDeferredJSONStorage<S>(() => getSafeStorage(), options)
);
const createDeferredStorage = (storage: Storage): Storage => {
const pendingWrites = new Map<string, string>();
const pendingDeletes = new Set<string>();
let flushTimer: ReturnType<typeof setTimeout> | 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 => { const getWindowStorage = (key: 'localStorage' | 'sessionStorage'): Storage | null => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -135,6 +321,13 @@ export const getSafeStorage = (): Storage => {
return safeStorageInstance; return safeStorageInstance;
}; };
export const getDeferredSafeStorage = (): Storage => {
if (!deferredSafeStorageInstance) {
deferredSafeStorageInstance = createDeferredStorage(getSafeStorage());
}
return deferredSafeStorageInstance;
};
const createSafeSessionStorage = (): Storage => { const createSafeSessionStorage = (): Storage => {
const baseStorage = getWindowStorage('sessionStorage'); const baseStorage = getWindowStorage('sessionStorage');
+3 -3
View File
@@ -4,8 +4,8 @@
*/ */
import { create } from "zustand" import { create } from "zustand"
import { persist, createJSONStorage } from "zustand/middleware" import { persist } from "zustand/middleware"
import { getSafeStorage } from "@/stores/utils/safeStorage" import { createDeferredSafeJSONStorage } from "@/stores/utils/safeStorage"
type ModelSelection = { providerId: string; modelId: string } type ModelSelection = { providerId: string; modelId: string }
type LastUsedProvider = { providerID: string; modelID: string } type LastUsedProvider = { providerID: string; modelID: string }
@@ -126,7 +126,7 @@ export const useSelectionStore = create<SelectionState>()(
{ {
name: "selection-store", name: "selection-store",
version: 1, version: 1,
storage: createJSONStorage(() => getSafeStorage()), storage: createDeferredSafeJSONStorage(),
partialize: (state) => { partialize: (state) => {
// Convert Maps to arrays and slice to keep only the most recent MAX_PERSISTED_SESSIONS // 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) const models = Array.from(state.sessionModelSelections.entries()).slice(-MAX_PERSISTED_SESSIONS)
+4 -4
View File
@@ -25,7 +25,7 @@ import { useDirectoryStore } from "@/stores/useDirectoryStore"
import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore" import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore"
import { useCommandsStore } from "@/stores/useCommandsStore" import { useCommandsStore } from "@/stores/useCommandsStore"
import { useSkillsStore } from "@/stores/useSkillsStore" import { useSkillsStore } from "@/stores/useSkillsStore"
import { getSafeStorage } from "@/stores/utils/safeStorage" import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
@@ -327,7 +327,7 @@ const resolveDirectoryKey = (session: Session): string | null => {
?? normalizePath(sessionRecord.project?.worktree ?? null) ?? normalizePath(sessionRecord.project?.worktree ?? null)
} }
const safeStorage = getSafeStorage() const safeStorage = getDeferredSafeStorage()
const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget" const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget"
type PersistedDraftTarget = { projectId: string | null; directory: string | null } type PersistedDraftTarget = { projectId: string | null; directory: string | null }
@@ -515,7 +515,7 @@ const WORKTREE_MAP_STORAGE_KEY = 'oc.worktreeMap'
const loadPersistedWorktreeMap = (): Map<string, WorktreeMetadata[]> => { const loadPersistedWorktreeMap = (): Map<string, WorktreeMetadata[]> => {
try { try {
const raw = getSafeStorage().getItem(WORKTREE_MAP_STORAGE_KEY) const raw = getDeferredSafeStorage().getItem(WORKTREE_MAP_STORAGE_KEY)
if (!raw) return new Map() if (!raw) return new Map()
const entries = JSON.parse(raw) as Array<[string, WorktreeMetadata[]]> const entries = JSON.parse(raw) as Array<[string, WorktreeMetadata[]]>
if (!Array.isArray(entries)) return new Map() if (!Array.isArray(entries)) return new Map()
@@ -529,7 +529,7 @@ const loadPersistedWorktreeMap = (): Map<string, WorktreeMetadata[]> => {
const persistWorktreeMap = (map: Map<string, WorktreeMetadata[]>): void => { const persistWorktreeMap = (map: Map<string, WorktreeMetadata[]>): void => {
try { try {
getSafeStorage().setItem(WORKTREE_MAP_STORAGE_KEY, JSON.stringify([...map.entries()])) getDeferredSafeStorage().setItem(WORKTREE_MAP_STORAGE_KEY, JSON.stringify([...map.entries()]))
} catch { } catch {
// quota / serialization error — ignore; discovery still refreshes at runtime // quota / serialization error — ignore; discovery still refreshes at runtime
} }