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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
088a70fe5a
commit
1505274f94
@@ -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,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();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user