diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 71d4c54ba23..9fcb3d6ad97 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -6,6 +6,7 @@ import { environmentKeys } from '@/hooks/queries/environment' import { useExecutionStore } from '@/stores/execution' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal' +import { clearPersistedUndoRedo } from '@/stores/undo-redo/storage' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -55,6 +56,9 @@ export async function clearUserData(): Promise { const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key)) keysToRemove.forEach((key) => localStorage.removeItem(key)) + // Persisted undo/redo lives in IndexedDB; remove it as well. + await clearPersistedUndoRedo() + logger.info('User data cleared successfully') } catch (error) { logger.error('Error clearing user data:', { error }) diff --git a/apps/sim/stores/undo-redo/storage.test.ts b/apps/sim/stores/undo-redo/storage.test.ts new file mode 100644 index 00000000000..5e5d6f0b30a --- /dev/null +++ b/apps/sim/stores/undo-redo/storage.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { idbStore, idbGet, idbSet, idbDel } = vi.hoisted(() => { + const store = new Map() + return { + idbStore: store, + idbGet: vi.fn(async (key: string) => store.get(key) ?? undefined), + idbSet: vi.fn(async (key: string, value: unknown) => { + store.set(key, value) + }), + idbDel: vi.fn(async (key: string) => { + store.delete(key) + }), + } +}) + +vi.mock('idb-keyval', () => ({ + get: idbGet, + set: idbSet, + del: idbDel, +})) + +const STORE_KEY = 'workflow-undo-redo' +const MIGRATION_KEY = 'workflow-undo-redo-migrated' + +async function loadFreshModule() { + vi.resetModules() + return await import('@/stores/undo-redo/storage') +} + +describe('undo-redo IndexedDB storage adapter', () => { + beforeEach(() => { + idbStore.clear() + idbGet.mockClear() + idbSet.mockClear() + idbDel.mockClear() + localStorage.clear() + vi.mocked(localStorage.getItem).mockClear() + vi.mocked(localStorage.setItem).mockClear() + vi.mocked(localStorage.removeItem).mockClear() + }) + + describe('migration', () => { + it('copies localStorage data into IndexedDB and removes the localStorage key on first load', async () => { + const legacyPayload = JSON.stringify({ state: { stacks: {} }, version: 0 }) + localStorage.setItem(STORE_KEY, legacyPayload) + idbSet.mockClear() + + const { migrationReady } = await loadFreshModule() + await migrationReady + + expect(idbSet).toHaveBeenCalledWith(STORE_KEY, legacyPayload) + expect(idbStore.get(STORE_KEY)).toBe(legacyPayload) + expect(localStorage.getItem(STORE_KEY)).toBeNull() + expect(idbStore.get(MIGRATION_KEY)).toBe(true) + }) + + it('skips data copy when localStorage is empty but still marks migration complete', async () => { + const { migrationReady } = await loadFreshModule() + await migrationReady + + expect(idbSet).toHaveBeenCalledWith(MIGRATION_KEY, true) + expect(idbSet).not.toHaveBeenCalledWith(STORE_KEY, expect.anything()) + expect(idbStore.get(MIGRATION_KEY)).toBe(true) + }) + + it('does not re-run when MIGRATION_KEY is already set', async () => { + idbStore.set(MIGRATION_KEY, true) + const legacyPayload = JSON.stringify({ state: { stacks: { foo: {} } }, version: 0 }) + localStorage.setItem(STORE_KEY, legacyPayload) + + const { migrationReady } = await loadFreshModule() + await migrationReady + + expect(idbSet).not.toHaveBeenCalledWith(STORE_KEY, expect.anything()) + expect(localStorage.getItem(STORE_KEY)).toBe(legacyPayload) + }) + + it('does not throw when IndexedDB set fails — leaves localStorage intact for retry', async () => { + idbSet.mockRejectedValueOnce(new Error('idb write failed')) + const legacyPayload = JSON.stringify({ state: { stacks: {} }, version: 0 }) + localStorage.setItem(STORE_KEY, legacyPayload) + + const { migrationReady } = await loadFreshModule() + await expect(migrationReady).resolves.toBeUndefined() + + expect(localStorage.getItem(STORE_KEY)).toBe(legacyPayload) + }) + }) + + describe('storage adapter', () => { + it('getItem awaits migration completion before reading', async () => { + const legacyPayload = JSON.stringify({ state: { stacks: { a: {} } }, version: 0 }) + localStorage.setItem(STORE_KEY, legacyPayload) + + const { indexedDBStorage, migrationReady } = await loadFreshModule() + const readPromise = indexedDBStorage.getItem(STORE_KEY) + await migrationReady + const value = await readPromise + + expect(value).toBe(legacyPayload) + }) + + it('getItem returns null when key is absent', async () => { + const { indexedDBStorage, migrationReady } = await loadFreshModule() + await migrationReady + + const value = await indexedDBStorage.getItem('does-not-exist') + expect(value).toBeNull() + }) + + it('setItem writes through to IndexedDB', async () => { + const { indexedDBStorage, migrationReady } = await loadFreshModule() + await migrationReady + + await indexedDBStorage.setItem(STORE_KEY, 'new-value') + expect(idbStore.get(STORE_KEY)).toBe('new-value') + }) + + it('setItem swallows IndexedDB errors so the store never crashes the app', async () => { + const { indexedDBStorage, migrationReady } = await loadFreshModule() + await migrationReady + + idbSet.mockRejectedValueOnce(new Error('idb quota')) + await expect(indexedDBStorage.setItem(STORE_KEY, 'x')).resolves.toBeUndefined() + }) + + it('removeItem deletes from IndexedDB', async () => { + const { indexedDBStorage, migrationReady } = await loadFreshModule() + await migrationReady + idbStore.set(STORE_KEY, 'present') + + await indexedDBStorage.removeItem(STORE_KEY) + expect(idbStore.has(STORE_KEY)).toBe(false) + }) + + it('removeItem swallows IndexedDB errors', async () => { + const { indexedDBStorage, migrationReady } = await loadFreshModule() + await migrationReady + + idbDel.mockRejectedValueOnce(new Error('idb delete failed')) + await expect(indexedDBStorage.removeItem(STORE_KEY)).resolves.toBeUndefined() + }) + + it('getItem swallows IndexedDB read errors and returns null', async () => { + const { indexedDBStorage, migrationReady } = await loadFreshModule() + await migrationReady + + idbGet.mockRejectedValueOnce(new Error('idb read failed')) + const value = await indexedDBStorage.getItem(STORE_KEY) + expect(value).toBeNull() + }) + }) + + describe('clearPersistedUndoRedo', () => { + it('deletes the undo-redo key from IndexedDB', async () => { + const { clearPersistedUndoRedo, migrationReady } = await loadFreshModule() + await migrationReady + idbStore.set(STORE_KEY, 'present') + + await clearPersistedUndoRedo() + + expect(idbStore.has(STORE_KEY)).toBe(false) + }) + + it('leaves the migration flag intact so migration does not re-run', async () => { + const { clearPersistedUndoRedo, migrationReady } = await loadFreshModule() + await migrationReady + idbStore.set(STORE_KEY, 'present') + + await clearPersistedUndoRedo() + + expect(idbStore.get(MIGRATION_KEY)).toBe(true) + }) + + it('propagates IndexedDB errors so callers can surface the failure', async () => { + const { clearPersistedUndoRedo, migrationReady } = await loadFreshModule() + await migrationReady + + idbDel.mockRejectedValueOnce(new Error('idb delete failed')) + await expect(clearPersistedUndoRedo()).rejects.toThrow('idb delete failed') + }) + }) + + describe('hydration race', () => { + it('blocks setItem until the first getItem resolves', async () => { + const { indexedDBStorage, migrationReady } = await loadFreshModule() + await migrationReady + idbStore.set(STORE_KEY, 'persisted-snapshot') + + let releaseRead: ((value: 'persisted-snapshot') => void) | null = null + idbGet.mockImplementationOnce( + () => + new Promise<'persisted-snapshot'>((resolve) => { + releaseRead = resolve + }) + ) + + const readPromise = indexedDBStorage.getItem(STORE_KEY) + const writePromise = indexedDBStorage.setItem(STORE_KEY, 'empty-state') + + // Give the microtask queue a chance to process; the write must still be pending. + await Promise.resolve() + expect(idbStore.get(STORE_KEY)).toBe('persisted-snapshot') + + releaseRead?.('persisted-snapshot') + await readPromise + await writePromise + + expect(idbStore.get(STORE_KEY)).toBe('empty-state') + }) + }) +}) diff --git a/apps/sim/stores/undo-redo/storage.ts b/apps/sim/stores/undo-redo/storage.ts new file mode 100644 index 00000000000..5c0cc9773bb --- /dev/null +++ b/apps/sim/stores/undo-redo/storage.ts @@ -0,0 +1,142 @@ +import { createLogger } from '@sim/logger' +import { del, get, set } from 'idb-keyval' +import type { StateStorage } from 'zustand/middleware' + +const logger = createLogger('UndoRedoStorage') + +const STORE_KEY = 'workflow-undo-redo' +const MIGRATION_KEY = 'workflow-undo-redo-migrated' + +let migrationPromiseInternal: Promise | null = null + +/** + * Resolves with the first `getItem` result that goes through the adapter. + * Used to gate writes until the initial rehydration read completes — without + * this, a `setItem` triggered before the async `getItem` returns would + * overwrite the IndexedDB snapshot with an empty in-memory state. + */ +let hydrationReadPromise: Promise | null = null + +/** + * Migrates existing undo/redo data from localStorage to IndexedDB. + * Runs once on first load; subsequent loads short-circuit on MIGRATION_KEY. + * + * On success the localStorage key is removed, freeing origin storage quota + * for the other persisted Zustand stores that share it. + */ +async function migrateFromLocalStorage(): Promise { + if (typeof window === 'undefined') return + + try { + const migrated = await get(MIGRATION_KEY) + if (migrated) return + + const localData = localStorage.getItem(STORE_KEY) + if (localData) { + await set(STORE_KEY, localData) + localStorage.removeItem(STORE_KEY) + logger.info('Migrated undo-redo store from localStorage to IndexedDB') + } + + await set(MIGRATION_KEY, true) + } catch (error) { + logger.warn('Migration from localStorage failed', { error }) + } +} + +if (typeof window !== 'undefined') { + migrationPromiseInternal = migrateFromLocalStorage().finally(() => { + migrationPromiseInternal = null + }) +} + +/** + * Resolves when the one-time localStorage → IndexedDB migration finishes. + * Exposed for tests; production code reads through `indexedDBStorage` + * methods which already await this promise. + */ +export const migrationReady: Promise = migrationPromiseInternal ?? Promise.resolve() + +async function awaitMigration(): Promise { + if (migrationPromiseInternal) { + await migrationPromiseInternal + } +} + +async function awaitHydrationRead(): Promise { + if (hydrationReadPromise) { + try { + await hydrationReadPromise + } catch { + // The read promise already swallowed its own errors; ignore here. + } + } +} + +/** + * Removes the persisted undo/redo payload from IndexedDB. + * + * Called from `clearUserData` on sign-out so undo history does not + * survive across user sessions on the same device. Errors are + * propagated so callers can decide how to react (the default + * `clearUserData` already wraps this call in a try/catch). + */ +export async function clearPersistedUndoRedo(): Promise { + if (typeof window === 'undefined') return + await awaitMigration() + + try { + await del(STORE_KEY) + } catch (error) { + logger.warn('Failed to clear persisted undo-redo', { error }) + throw error + } +} + +export const indexedDBStorage: StateStorage = { + getItem: async (name: string): Promise => { + if (typeof window === 'undefined') return null + await awaitMigration() + + const readPromise = (async () => { + try { + const value = await get(name) + return value ?? null + } catch (error) { + logger.warn('IndexedDB read failed', { name, error }) + return null + } + })() + + // Record the first read so concurrent writes can wait for it to complete. + if (!hydrationReadPromise) { + hydrationReadPromise = readPromise + } + + return await readPromise + }, + + setItem: async (name: string, value: string): Promise => { + if (typeof window === 'undefined') return + await awaitMigration() + await awaitHydrationRead() + + try { + await set(name, value) + } catch (error) { + logger.warn('IndexedDB write failed', { name, error }) + } + }, + + removeItem: async (name: string): Promise => { + if (typeof window === 'undefined') return + await awaitMigration() + await awaitHydrationRead() + + try { + await del(name) + } catch (error) { + logger.warn('IndexedDB delete failed', { name, error }) + } + }, +} diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index 30ec1a3a237..69aa330c411 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -3,6 +3,7 @@ import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants' import type { Edge } from 'reactflow' import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' +import { indexedDBStorage } from '@/stores/undo-redo/storage' import type { BatchAddBlocksOperation, BatchAddEdgesOperation, @@ -47,41 +48,6 @@ function getStackKey(workflowId: string, userId: string): string { return `${workflowId}:${userId}` } -/** - * Custom storage adapter for Zustand's persist middleware. - * We need this wrapper to gracefully handle 'QuotaExceededError' when localStorage is full, - * Without this, the default storage engine would throw and crash the application. - * and to properly handle SSR/Node.js environments. - */ -const safeStorageAdapter = { - getItem: (name: string): string | null => { - if (typeof localStorage === 'undefined') return null - try { - return localStorage.getItem(name) - } catch (e) { - logger.warn('Failed to read from localStorage', e) - return null - } - }, - setItem: (name: string, value: string): void => { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(name, value) - } catch (e) { - // Log warning but don't crash - this handles QuotaExceededError - logger.warn('Failed to save to localStorage', e) - } - }, - removeItem: (name: string): void => { - if (typeof localStorage === 'undefined') return - try { - localStorage.removeItem(name) - } catch (e) { - logger.warn('Failed to remove from localStorage', e) - } - }, -} - function isOperationApplicable( operation: Operation, graph: { blocksById: Record; edgesById: Record } @@ -501,7 +467,7 @@ export const useUndoRedoStore = create()( }), { name: 'workflow-undo-redo', - storage: createJSONStorage(() => safeStorageAdapter), + storage: createJSONStorage(() => indexedDBStorage), partialize: (state) => ({ stacks: state.stacks, capacity: state.capacity,