👻 refactor: LocalStorage Cleanup and MCP State Optimization (#9528)

* 👻 refactor: MCP Select State with Jotai Atoms

* refactor: Implement timestamp management for ChatArea localStorage entries

* refactor: Integrate MCP Server Manager into BadgeRow context and components to avoid double-calling within BadgeRow

* refactor: add try/catch

* chore: remove comment
This commit is contained in:
Danny Avila 2025-09-09 17:32:10 -04:00 committed by GitHub
parent 519645c0b0
commit 751c2e1d17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 435 additions and 115 deletions

View file

@ -3,12 +3,14 @@ import { useSetRecoilState } from 'recoil';
import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider';
import type { TAgentsEndpoint } from 'librechat-data-provider';
import {
useMCPServerManager,
useSearchApiKeyForm,
useGetAgentsConfig,
useCodeApiKeyForm,
useGetMCPTools,
useToolToggle,
} from '~/hooks';
import { getTimestampedValue, setTimestamp } from '~/utils/timestamps';
import { ephemeralAgentByConvoId } from '~/store';
interface BadgeRowContextType {
@ -21,6 +23,7 @@ interface BadgeRowContextType {
codeInterpreter: ReturnType<typeof useToolToggle>;
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
mcpServerManager: ReturnType<typeof useMCPServerManager>;
}
const BadgeRowContext = createContext<BadgeRowContextType | undefined>(undefined);
@ -62,16 +65,15 @@ export default function BadgeRowProvider({
hasInitializedRef.current = true;
lastKeyRef.current = key;
// Load all localStorage values
const codeToggleKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`;
const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`;
const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${key}`;
const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${key}`;
const codeToggleValue = localStorage.getItem(codeToggleKey);
const webSearchToggleValue = localStorage.getItem(webSearchToggleKey);
const fileSearchToggleValue = localStorage.getItem(fileSearchToggleKey);
const artifactsToggleValue = localStorage.getItem(artifactsToggleKey);
const codeToggleValue = getTimestampedValue(codeToggleKey);
const webSearchToggleValue = getTimestampedValue(webSearchToggleKey);
const fileSearchToggleValue = getTimestampedValue(fileSearchToggleKey);
const artifactsToggleValue = getTimestampedValue(artifactsToggleKey);
const initialValues: Record<string, any> = {};
@ -107,15 +109,37 @@ export default function BadgeRowProvider({
}
}
// Always set values for all tools (use defaults if not in localStorage)
// If ephemeralAgent is null, create a new object with just our tool values
setEphemeralAgent((prev) => ({
...(prev || {}),
/**
* Always set values for all tools (use defaults if not in `localStorage`)
* If `ephemeralAgent` is `null`, create a new object with just our tool values
*/
const finalValues = {
[Tools.execute_code]: initialValues[Tools.execute_code] ?? false,
[Tools.web_search]: initialValues[Tools.web_search] ?? false,
[Tools.file_search]: initialValues[Tools.file_search] ?? false,
[AgentCapabilities.artifacts]: initialValues[AgentCapabilities.artifacts] ?? false,
};
setEphemeralAgent((prev) => ({
...(prev || {}),
...finalValues,
}));
Object.entries(finalValues).forEach(([toolKey, value]) => {
if (value !== false) {
let storageKey = artifactsToggleKey;
if (toolKey === Tools.execute_code) {
storageKey = codeToggleKey;
} else if (toolKey === Tools.web_search) {
storageKey = webSearchToggleKey;
} else if (toolKey === Tools.file_search) {
storageKey = fileSearchToggleKey;
}
// Store the value and set timestamp for existing values
localStorage.setItem(storageKey, JSON.stringify(value));
setTimestamp(storageKey);
}
});
}
}, [key, isSubmitting, setEphemeralAgent]);
@ -165,6 +189,8 @@ export default function BadgeRowProvider({
isAuthenticated: true,
});
const mcpServerManager = useMCPServerManager({ conversationId });
const mcpServerNames = useMemo(() => {
return (mcpToolDetails ?? []).map((tool) => tool.name);
}, [mcpToolDetails]);
@ -179,6 +205,7 @@ export default function BadgeRowProvider({
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
mcpServerManager,
};
return <BadgeRowContext.Provider value={value}>{children}</BadgeRowContext.Provider>;

View file

@ -368,7 +368,7 @@ function BadgeRow({
<CodeInterpreter />
<FileSearch />
<Artifacts />
<MCPSelect conversationId={conversationId} />
<MCPSelect />
</>
)}
{ghostBadge && (

View file

@ -3,22 +3,20 @@ import { MultiSelect, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useBadgeRowContext } from '~/Providers';
import { useMCPServerManager } from '~/hooks';
type MCPSelectProps = { conversationId?: string | null };
function MCPSelectContent({ conversationId }: MCPSelectProps) {
function MCPSelectContent() {
const { conversationId, mcpServerManager } = useBadgeRowContext();
const {
configuredServers,
mcpValues,
isPinned,
placeholderText,
batchToggleServers,
getServerStatusIconProps,
getConfigDialogProps,
isInitializing,
localize,
} = useMCPServerManager({ conversationId });
isPinned,
mcpValues,
isInitializing,
placeholderText,
configuredServers,
batchToggleServers,
getConfigDialogProps,
getServerStatusIconProps,
} = mcpServerManager;
const renderSelectedValues = useCallback(
(values: string[], placeholder?: string) => {
@ -103,10 +101,10 @@ function MCPSelectContent({ conversationId }: MCPSelectProps) {
);
}
function MCPSelect(props: MCPSelectProps) {
function MCPSelect() {
const { mcpServerNames } = useBadgeRowContext();
if ((mcpServerNames?.length ?? 0) === 0) return null;
return <MCPSelectContent {...props} />;
return <MCPSelectContent />;
}
export default memo(MCPSelect);

View file

@ -4,27 +4,27 @@ import { ChevronRight } from 'lucide-react';
import { PinIcon, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useMCPServerManager } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
import { cn } from '~/utils';
interface MCPSubMenuProps {
placeholder?: string;
conversationId?: string | null;
}
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
({ placeholder, conversationId, ...props }, ref) => {
({ placeholder, ...props }, ref) => {
const { mcpServerManager } = useBadgeRowContext();
const {
configuredServers,
mcpValues,
isPinned,
mcpValues,
setIsPinned,
isInitializing,
placeholderText,
configuredServers,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
getConfigDialogProps,
isInitializing,
} = useMCPServerManager({ conversationId });
} = mcpServerManager;
const menuStore = Ariakit.useMenuStore({
focusLoop: true,

View file

@ -31,7 +31,6 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
fileSearch,
agentsConfig,
mcpServerNames,
conversationId,
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
@ -290,9 +289,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
if (mcpServerNames && mcpServerNames.length > 0) {
dropdownItems.push({
hideOnClick: false,
render: (props) => (
<MCPSubMenu {...props} placeholder={mcpPlaceholder} conversationId={conversationId} />
),
render: (props) => <MCPSubMenu {...props} placeholder={mcpPlaceholder} />,
});
}

View file

@ -5,6 +5,7 @@ import { LocalStorageKeys } from 'librechat-data-provider';
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
import type { TStartupConfig, TPlugin, TUser } from 'librechat-data-provider';
import { mapPlugins, selectPlugins, processPlugins } from '~/utils';
import { cleanupTimestampedStorage } from '~/utils/timestamps';
import useSpeechSettingsInit from './useSpeechSettingsInit';
import store from '~/store';
@ -34,6 +35,11 @@ export default function useAppStartup({
useSpeechSettingsInit(!!user);
/** Clean up old localStorage entries on startup */
useEffect(() => {
cleanupTimestampedStorage();
}, []);
/** Set the app title */
useEffect(() => {
const appTitle = startupConfig?.appTitle ?? '';

View file

@ -1,66 +1,50 @@
import { useRef, useCallback, useMemo } from 'react';
import { useCallback, useEffect } from 'react';
import { useAtom } from 'jotai';
import { useRecoilState } from 'recoil';
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { ephemeralAgentByConvoId } from '~/store';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue.length > 2) {
return true;
}
} catch (e) {
console.error(e);
}
}
return Array.isArray(value) && value.length > 0;
};
import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store';
import { setTimestamp } from '~/utils/timestamps';
export function useMCPSelect({ conversationId }: { conversationId?: string | null }) {
const key = conversationId ?? Constants.NEW_CONVO;
const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom);
const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key));
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const storageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
const mcpState = useMemo(() => {
return ephemeralAgent?.mcp ?? [];
}, [ephemeralAgent?.mcp]);
// Sync Jotai state with ephemeral agent state
useEffect(() => {
if (ephemeralAgent?.mcp && ephemeralAgent.mcp.length > 0) {
setMCPValuesRaw(ephemeralAgent.mcp);
}
}, [ephemeralAgent?.mcp, setMCPValuesRaw]);
const setSelectedValues = useCallback(
(values: string[] | null | undefined) => {
if (!values) {
return;
}
if (!Array.isArray(values)) {
return;
}
// Update ephemeral agent when Jotai state changes
useEffect(() => {
if (mcpValues.length > 0 && JSON.stringify(mcpValues) !== JSON.stringify(ephemeralAgent?.mcp)) {
setEphemeralAgent((prev) => ({
...prev,
mcp: values,
mcp: mcpValues,
}));
}
}, [mcpValues, ephemeralAgent?.mcp, setEphemeralAgent]);
useEffect(() => {
const mcpStorageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
if (mcpValues.length > 0) {
setTimestamp(mcpStorageKey);
}
}, [mcpValues, key]);
/** Stable memoized setter */
const setMCPValues = useCallback(
(value: string[]) => {
if (!Array.isArray(value)) {
return;
}
setMCPValuesRaw(value);
},
[setEphemeralAgent],
);
const [mcpValues, setMCPValuesRaw] = useLocalStorage<string[]>(
storageKey,
mcpState,
setSelectedValues,
storageCondition,
);
const setMCPValuesRawRef = useRef(setMCPValuesRaw);
setMCPValuesRawRef.current = setMCPValuesRaw;
/** Create a stable memoized setter to avoid re-creating it on every render and causing an infinite render loop */
const setMCPValues = useCallback((value: string[]) => {
setMCPValuesRawRef.current(value);
}, []);
const [isPinned, setIsPinned] = useLocalStorage<boolean>(
`${LocalStorageKeys.PIN_MCP_}${key}`,
true,
[setMCPValuesRaw],
);
return {

View file

@ -25,9 +25,8 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const { mcpToolDetails } = useGetMCPTools();
const mcpSelect = useMCPSelect({ conversationId });
const { data: startupConfig } = useGetStartupConfig();
const { mcpValues, setMCPValues, isPinned, setIsPinned } = mcpSelect;
const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId });
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);

View file

@ -5,23 +5,10 @@ import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import type { VerifyToolAuthResponse } from 'librechat-data-provider';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useVerifyAgentToolAuth } from '~/data-provider';
import { setTimestamp } from '~/utils/timestamps';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { ephemeralAgentByConvoId } from '~/store';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue === 'true' && value === false) {
return true;
}
} catch (e) {
console.error(e);
}
}
return value !== undefined && value !== null;
};
type ToolValue = boolean | string;
interface UseToolToggleOptions {
@ -39,7 +26,7 @@ interface UseToolToggleOptions {
export function useToolToggle({
conversationId,
toolKey,
toolKey: _toolKey,
localStorageKey,
isAuthenticated: externalIsAuthenticated,
setIsDialogOpen,
@ -62,13 +49,8 @@ export function useToolToggle({
[externalIsAuthenticated, authConfig, authQuery.data?.authenticated],
);
// Keep localStorage in sync
const [, setLocalStorageValue] = useLocalStorage<ToolValue>(
`${localStorageKey}${key}`,
false,
undefined,
storageCondition,
);
const toolKey = useMemo(() => _toolKey, [_toolKey]);
const storageKey = useMemo(() => `${localStorageKey}${key}`, [localStorageKey, key]);
// The actual current value comes from ephemeralAgent
const toolValue = useMemo(() => {
@ -83,13 +65,14 @@ export function useToolToggle({
return toolValue === true;
}, [toolValue]);
// Sync to localStorage when ephemeralAgent changes
// Sync to localStorage with timestamps when ephemeralAgent changes
useEffect(() => {
const value = ephemeralAgent?.[toolKey];
if (value !== undefined) {
setLocalStorageValue(value);
localStorage.setItem(storageKey, JSON.stringify(value));
setTimestamp(storageKey);
}
}, [ephemeralAgent, toolKey, setLocalStorageValue]);
}, [ephemeralAgent, toolKey, storageKey]);
const [isPinned, setIsPinned] = useLocalStorage<boolean>(`${localStorageKey}pinned`, false);

View file

@ -13,6 +13,7 @@ import settings from './settings';
import misc from './misc';
import isTemporary from './temporary';
export * from './agents';
export * from './mcp';
export default {
...artifacts,

20
client/src/store/mcp.ts Normal file
View file

@ -0,0 +1,20 @@
import { atomFamily, atomWithStorage } from 'jotai/utils';
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
/**
* Creates a storage atom for MCP values per conversation
* Uses atomFamily to create unique atoms for each conversation ID
*/
export const mcpValuesAtomFamily = atomFamily((conversationId: string | null) => {
const key = conversationId ?? Constants.NEW_CONVO;
const storageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
return atomWithStorage<string[]>(storageKey, [], undefined, { getOnInit: true });
});
/**
* Global storage atom for MCP pinned state (shared across all conversations)
*/
export const mcpPinnedAtom = atomWithStorage<boolean>(LocalStorageKeys.PIN_MCP_, true, undefined, {
getOnInit: true,
});

View file

@ -0,0 +1,163 @@
import { LocalStorageKeys } from 'librechat-data-provider';
import {
setTimestamp,
setTimestampedValue,
getTimestampedValue,
removeTimestampedValue,
cleanupTimestampedStorage,
migrateExistingEntries,
} from '../timestamps';
describe('timestamps', () => {
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
describe('setTimestamp', () => {
it('should set only timestamp in localStorage', () => {
const key = 'test-key';
setTimestamp(key);
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeTruthy();
expect(localStorage.getItem(key)).toBeNull();
});
});
describe('setTimestampedValue', () => {
it('should set value and timestamp in localStorage', () => {
const key = 'test-key';
const value = { data: 'test' };
setTimestampedValue(key, value);
expect(localStorage.getItem(key)).toBe(JSON.stringify(value));
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeTruthy();
});
});
describe('getTimestampedValue', () => {
it('should return value if timestamp is valid', () => {
const key = 'test-key';
const value = { data: 'test' };
localStorage.setItem(key, JSON.stringify(value));
localStorage.setItem(`${key}_TIMESTAMP`, Date.now().toString());
const result = getTimestampedValue(key);
expect(result).toBe(JSON.stringify(value));
});
it('should return null and clean up if timestamp is too old', () => {
const key = 'test-key';
const value = { data: 'test' };
const oldTimestamp = Date.now() - 3 * 24 * 60 * 60 * 1000; // 3 days ago
localStorage.setItem(key, JSON.stringify(value));
localStorage.setItem(`${key}_TIMESTAMP`, oldTimestamp.toString());
const result = getTimestampedValue(key);
expect(result).toBeNull();
expect(localStorage.getItem(key)).toBeNull();
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeNull();
});
it('should return value if no timestamp exists (backward compatibility)', () => {
const key = 'test-key';
const value = { data: 'test' };
localStorage.setItem(key, JSON.stringify(value));
const result = getTimestampedValue(key);
expect(result).toBe(JSON.stringify(value));
});
});
describe('removeTimestampedValue', () => {
it('should remove both value and timestamp', () => {
const key = 'test-key';
localStorage.setItem(key, 'value');
localStorage.setItem(`${key}_TIMESTAMP`, Date.now().toString());
removeTimestampedValue(key);
expect(localStorage.getItem(key)).toBeNull();
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeNull();
});
});
describe('cleanupTimestampedStorage', () => {
it('should remove entries without timestamps', () => {
const key = `${LocalStorageKeys.LAST_MCP_}convo-123`;
localStorage.setItem(key, 'value');
cleanupTimestampedStorage();
expect(localStorage.getItem(key)).toBeNull();
});
it('should remove old entries with timestamps', () => {
const key = `${LocalStorageKeys.LAST_CODE_TOGGLE_}convo-456`;
const oldTimestamp = Date.now() - 3 * 24 * 60 * 60 * 1000; // 3 days ago
localStorage.setItem(key, 'true');
localStorage.setItem(`${key}_TIMESTAMP`, oldTimestamp.toString());
cleanupTimestampedStorage();
expect(localStorage.getItem(key)).toBeNull();
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeNull();
});
it('should keep recent entries', () => {
const key = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}convo-789`;
const recentTimestamp = Date.now() - 1 * 60 * 60 * 1000; // 1 hour ago
localStorage.setItem(key, 'false');
localStorage.setItem(`${key}_TIMESTAMP`, recentTimestamp.toString());
cleanupTimestampedStorage();
expect(localStorage.getItem(key)).toBe('false');
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBe(recentTimestamp.toString());
});
it('should not affect non-timestamped keys', () => {
const regularKey = 'regular-key';
localStorage.setItem(regularKey, 'value');
cleanupTimestampedStorage();
expect(localStorage.getItem(regularKey)).toBe('value');
});
});
describe('migrateExistingEntries', () => {
it('should add timestamps to existing timestamped keys', () => {
const key1 = `${LocalStorageKeys.LAST_MCP_}convo-111`;
const key2 = `${LocalStorageKeys.PIN_MCP_}convo-222`;
localStorage.setItem(key1, '["mcp1", "mcp2"]');
localStorage.setItem(key2, 'true');
migrateExistingEntries();
expect(localStorage.getItem(`${key1}_TIMESTAMP`)).toBeTruthy();
expect(localStorage.getItem(`${key2}_TIMESTAMP`)).toBeTruthy();
});
it('should not overwrite existing timestamps', () => {
const key = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}convo-333`;
const existingTimestamp = '1234567890';
localStorage.setItem(key, 'true');
localStorage.setItem(`${key}_TIMESTAMP`, existingTimestamp);
migrateExistingEntries();
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBe(existingTimestamp);
});
});
});

View file

@ -19,6 +19,7 @@ export * from './roles';
export * from './localStorage';
export * from './promptGroups';
export * from './email';
export * from './timestamps';
export { default as cn } from './cn';
export { default as logger } from './logger';
export { default as scaleImage } from './scaleImage';

View file

@ -0,0 +1,141 @@
import { LocalStorageKeys } from 'librechat-data-provider';
/** Suffix for timestamp entries */
const TIMESTAMP_SUFFIX = '_TIMESTAMP';
/** Duration in milliseconds (2 days) */
const CLEANUP_THRESHOLD = 2 * 24 * 60 * 60 * 1000;
/**
* Storage keys that should be cleaned up based on timestamps
* These are conversation-specific keys that can accumulate over time
*/
const TIMESTAMPED_KEYS = [
LocalStorageKeys.LAST_MCP_,
LocalStorageKeys.LAST_CODE_TOGGLE_,
LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_,
LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_,
LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_,
LocalStorageKeys.PIN_MCP_,
];
/**
* Set only a timestamp for a key (when the value is handled elsewhere)
*/
export function setTimestamp(key: string): void {
localStorage.setItem(`${key}${TIMESTAMP_SUFFIX}`, Date.now().toString());
}
/**
* Set a value in localStorage with an associated timestamp
*/
export function setTimestampedValue(key: string, value: any): void {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
localStorage.setItem(`${key}${TIMESTAMP_SUFFIX}`, Date.now().toString());
}
/**
* Get a value from localStorage, checking if it has a valid timestamp
* Returns null if the value is too old or has no timestamp
*/
export function getTimestampedValue(key: string): string | null {
const timestampKey = `${key}${TIMESTAMP_SUFFIX}`;
const timestamp = localStorage.getItem(timestampKey);
if (!timestamp) {
// No timestamp exists, return the value but it will be cleaned up on next startup
return localStorage.getItem(key);
}
const age = Date.now() - parseInt(timestamp, 10);
if (age > CLEANUP_THRESHOLD) {
// Value is too old, clean it up
localStorage.removeItem(key);
localStorage.removeItem(timestampKey);
return null;
}
return localStorage.getItem(key);
}
/**
* Remove a value and its timestamp from localStorage
*/
export function removeTimestampedValue(key: string): void {
localStorage.removeItem(key);
localStorage.removeItem(`${key}${TIMESTAMP_SUFFIX}`);
}
/**
* Clean up old localStorage entries based on timestamps
* This should be called on app startup
*/
export function cleanupTimestampedStorage(): void {
try {
const keysToRemove: string[] = [];
const now = Date.now();
// Iterate through all localStorage keys
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
if (key === LocalStorageKeys.PIN_MCP_) {
continue;
}
// Check if this key should be timestamped
const isTimestampedKey = TIMESTAMPED_KEYS.some((prefix) => key.startsWith(prefix));
if (isTimestampedKey && !key.endsWith(TIMESTAMP_SUFFIX)) {
const timestampKey = `${key}${TIMESTAMP_SUFFIX}`;
const timestamp = localStorage.getItem(timestampKey);
if (!timestamp) {
// No timestamp exists for a key that should have one - mark for cleanup
keysToRemove.push(key);
continue;
}
const age = now - parseInt(timestamp, 10);
if (age > CLEANUP_THRESHOLD) {
// Entry is too old - mark for cleanup
keysToRemove.push(key);
keysToRemove.push(timestampKey);
}
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
if (keysToRemove.length > 0) {
console.log(`Cleaned up ${keysToRemove.length} old localStorage entries`);
}
} catch (error) {
console.error('Error during cleanup of timestamped storage:', error);
}
}
/**
* Migration function to add timestamps to existing entries
* This ensures existing entries don't get immediately cleaned up
*/
export function migrateExistingEntries(): void {
const now = Date.now().toString();
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
const isTimestampedKey = TIMESTAMPED_KEYS.some((prefix) => key.startsWith(prefix));
if (isTimestampedKey && !key.endsWith(TIMESTAMP_SUFFIX)) {
const timestampKey = `${key}${TIMESTAMP_SUFFIX}`;
const hasTimestamp = localStorage.getItem(timestampKey);
if (!hasTimestamp) {
// Add timestamp to existing entry
localStorage.setItem(timestampKey, now);
}
}
}
}