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 { 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,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();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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', () => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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 }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user