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 { 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<SessionSidebarProps> = ({
const [editTitle, setEditTitle] = React.useState('');
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null);
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 [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 [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
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<SessionSidebarProps> = ({
});
const [groupOrderByProject, setGroupOrderByProject] = React.useState<Map<string, string[]>>(() => {
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<SessionSidebarProps> = ({
});
const [activeSessionByProject, setActiveSessionByProject] = React.useState<Map<string, string>>(() => {
try {
const raw = getSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY);
const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY);
if (!raw) {
return new Map();
}
@@ -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);
},
+3 -3
View File
@@ -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();
},
},
+3 -3
View File
@@ -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
@@ -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
+3 -3
View File
@@ -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<ContextStore>()(
}),
{
name: "context-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
sessionModelSelections: Array.from(state.sessionModelSelections.entries()),
sessionAgentSelections: Array.from(state.sessionAgentSelections.entries()),
+3 -3
View File
@@ -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<MessageQueueStore>()(
{
name: 'message-queue-store',
version: 1,
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
queuedMessages: state.queuedMessages,
followUpBehavior: state.followUpBehavior,
+3 -3
View File
@@ -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<PermissionStore>()(
devtools(
+3 -3
View File
@@ -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<AgentsStore>()(
}),
{
name: "agents-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
selectedAgentName: state.selectedAgentName,
}),
+3 -3
View File
@@ -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<AutoReviewState>()(
}),
{
name: 'auto-review-store',
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ runsByOriginalSessionID: state.runsByOriginalSessionID }),
},
),
+3 -3
View File
@@ -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<CommandsStore>()(
}),
{
name: "commands-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
selectedCommandName: state.selectedCommandName,
}),
@@ -129,7 +129,23 @@ const deferred = <T,>() => {
};
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', () => ({
+3 -3
View File
@@ -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<ConfigStore>()(
}),
{
name: "config-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
merge: (persistedState, currentState) =>
hydrateActiveDirectorySnapshot({
...currentState,
+2 -2
View File
@@ -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;
@@ -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<FilesViewTabsStore>()(
{
name: 'files-view-tabs-store',
version: 2,
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') {
return { byRoot: {} };
@@ -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<GitHubPrStatusStore>()(
}),
{
name: PR_STATUS_STORAGE_KEY,
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
entries: Object.fromEntries(
Object.entries(state.entries)
@@ -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<GitIdentitiesStore>()(
}),
{
name: "git-identities-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
selectedProfileId: state.selectedProfileId,
}),
+3 -3
View File
@@ -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<string, GitBranch> => {
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<string, GitBranch>;
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
}
@@ -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<InlineCommentDraftStore>()(
}),
{
name: 'openchamber-inline-comment-drafts',
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
version: 1,
migrate: (persistedState: unknown) => {
if (!persistedState || typeof persistedState !== 'object') {
+3 -3
View File
@@ -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<McpConfigStore>()(
}),
{
name: 'mcp-config-store',
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ selectedMcpName: state.selectedMcpName }),
},
),
+3 -3
View File
@@ -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<PluginsStore>()(
}),
{
name: 'plugins-store',
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
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 { 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';
@@ -22,6 +22,7 @@ const safeStorage = {
} as Storage;
mock.module('./utils/safeStorage', () => ({
getDeferredSafeStorage: () => safeStorage,
getSafeStorage: () => safeStorage,
}));
@@ -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<typeof setTimeout> | null = null;
let diskHydrated = false;
let diskHydrationInFlight = false;
@@ -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<SessionPinnedStore>((set, get) => ({
ids: readPinned(safeStorage),
+3 -3
View File
@@ -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<SkillsStore>()(
}),
{
name: "skills-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
selectedSkillName: state.selectedSkillName,
}),
@@ -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<TodosPersistState>()(
{
name: 'openchamber-session-todos',
version: 1,
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ sessions: state.sessions }),
},
),
+3 -3
View File
@@ -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<UIStore>()(
}),
{
name: 'ui-store',
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
version: 10,
migrate: (persistedState, version) => {
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');
};
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', () => {
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<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 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 => {
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');
+3 -3
View File
@@ -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<SelectionState>()(
{
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)
+4 -4
View File
@@ -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<string, WorktreeMetadata[]> => {
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<string, WorktreeMetadata[]> => {
const persistWorktreeMap = (map: Map<string, WorktreeMetadata[]>): 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
}