From fe854cb8c1062317c9fde9fc5b911199c8c5baf6 Mon Sep 17 00:00:00 2001 From: JaeHyung Jang Date: Tue, 26 May 2026 17:27:32 +0900 Subject: [PATCH 1/5] feat(undo-redo): add IndexedDB storage adapter Add apps/sim/stores/undo-redo/storage.ts wrapping idb-keyval, mirroring the pattern of apps/sim/stores/terminal/console/storage.ts. Includes a one-time localStorage -> IndexedDB migration that runs on first module load and removes the legacy localStorage key after copying. storage.test.ts covers migration idempotency, graceful failure on IndexedDB errors, and basic get/set/remove behavior (10 cases). No behavioral change in this commit -- the adapter is unused until the follow-up swaps the store's persist middleware. Refs #4737 Signed-off-by: JaeHyung Jang --- apps/sim/stores/undo-redo/storage.test.ts | 149 ++++++++++++++++++++++ apps/sim/stores/undo-redo/storage.ts | 86 +++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 apps/sim/stores/undo-redo/storage.test.ts create mode 100644 apps/sim/stores/undo-redo/storage.ts 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..8cbea2b029f --- /dev/null +++ b/apps/sim/stores/undo-redo/storage.test.ts @@ -0,0 +1,149 @@ +/** + * @vitest-environment node + */ +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('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() + }) + }) +}) diff --git a/apps/sim/stores/undo-redo/storage.ts b/apps/sim/stores/undo-redo/storage.ts new file mode 100644 index 00000000000..b7e0ac6cfe5 --- /dev/null +++ b/apps/sim/stores/undo-redo/storage.ts @@ -0,0 +1,86 @@ +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 + +/** + * 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 localStorage === '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 localStorage !== 'undefined') { + migrationPromiseInternal = migrateFromLocalStorage().finally(() => { + migrationPromiseInternal = null + }) +} + +/** + * Resolves when the one-time localStorage → IndexedDB migration finishes. + * Exposed for tests; production code reads through `indexedDBStorage.getItem` + * which already awaits this promise. + */ +export const migrationReady: Promise = migrationPromiseInternal ?? Promise.resolve() + +export const indexedDBStorage: StateStorage = { + getItem: async (name: string): Promise => { + if (typeof localStorage === 'undefined') return null + + if (migrationPromiseInternal) { + await migrationPromiseInternal + } + + try { + const value = await get(name) + return value ?? null + } catch (error) { + logger.warn('IndexedDB read failed', { name, error }) + return null + } + }, + + setItem: async (name: string, value: string): Promise => { + if (typeof localStorage === 'undefined') return + try { + await set(name, value) + } catch (error) { + logger.warn('IndexedDB write failed', { name, error }) + } + }, + + removeItem: async (name: string): Promise => { + if (typeof localStorage === 'undefined') return + try { + await del(name) + } catch (error) { + logger.warn('IndexedDB delete failed', { name, error }) + } + }, +} From d9201d87854cb72f8de821a919cbcc4bcd78404b Mon Sep 17 00:00:00 2001 From: JaeHyung Jang Date: Tue, 26 May 2026 17:27:45 +0900 Subject: [PATCH 2/5] feat(undo-redo): migrate persistence from localStorage to IndexedDB Replace inline safeStorageAdapter with indexedDBStorage. Workflow undo/ redo persistence moves from localStorage (~5MB origin cap) to IndexedDB (~GB), eliminating QuotaExceededError that can surface from any small persisted Zustand store (notification-storage, panel-editor-state, etc.) once workflow-undo-redo occupies the bulk of the origin's localStorage budget. Same pattern as PR #2812 (terminal-console -> IndexedDB) and aligns with PR #497's policy of minimizing localStorage usage. The earlier safeStorageAdapter (PR #2079) silently swallowed quota throws in this store but did not free the origin budget that the other small UI stores share, leaving them to throw uncaught QuotaExceededError. Fixes #4737 Signed-off-by: JaeHyung Jang --- apps/sim/stores/undo-redo/store.ts | 38 ++---------------------------- 1 file changed, 2 insertions(+), 36 deletions(-) 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, From 54a70cc68230a5cc679f8425faf70059d9192531 Mon Sep 17 00:00:00 2001 From: JaeHyung Jang Date: Tue, 26 May 2026 17:55:42 +0900 Subject: [PATCH 3/5] fix(undo-redo): address review feedback - Gate setItem/removeItem on migration completion via shared awaitMigration() helper. Previously only getItem awaited the one-time localStorage -> IndexedDB migration; a setItem racing the migration could be overwritten when migration completed (Cursor Bugbot, high). - Use `typeof window` for the SSR guard to match apps/sim/stores/terminal/console/storage.ts (Greptile). - Add error-swallowing test for removeItem to mirror the existing coverage on getItem and setItem (Greptile). - Switch storage.test.ts to `@vitest-environment jsdom` since the module-load IIFE depends on `typeof window`. Signed-off-by: JaeHyung Jang --- apps/sim/stores/undo-redo/storage.test.ts | 10 +++++++- apps/sim/stores/undo-redo/storage.ts | 29 ++++++++++++++--------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/sim/stores/undo-redo/storage.test.ts b/apps/sim/stores/undo-redo/storage.test.ts index 8cbea2b029f..13c708fc415 100644 --- a/apps/sim/stores/undo-redo/storage.test.ts +++ b/apps/sim/stores/undo-redo/storage.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment node + * @vitest-environment jsdom */ import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -137,6 +137,14 @@ describe('undo-redo IndexedDB storage adapter', () => { 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 diff --git a/apps/sim/stores/undo-redo/storage.ts b/apps/sim/stores/undo-redo/storage.ts index b7e0ac6cfe5..b4ad0226641 100644 --- a/apps/sim/stores/undo-redo/storage.ts +++ b/apps/sim/stores/undo-redo/storage.ts @@ -17,7 +17,7 @@ let migrationPromiseInternal: Promise | null = null * for the other persisted Zustand stores that share it. */ async function migrateFromLocalStorage(): Promise { - if (typeof localStorage === 'undefined') return + if (typeof window === 'undefined') return try { const migrated = await get(MIGRATION_KEY) @@ -36,7 +36,7 @@ async function migrateFromLocalStorage(): Promise { } } -if (typeof localStorage !== 'undefined') { +if (typeof window !== 'undefined') { migrationPromiseInternal = migrateFromLocalStorage().finally(() => { migrationPromiseInternal = null }) @@ -44,18 +44,21 @@ if (typeof localStorage !== 'undefined') { /** * Resolves when the one-time localStorage → IndexedDB migration finishes. - * Exposed for tests; production code reads through `indexedDBStorage.getItem` - * which already awaits this promise. + * 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 + } +} + export const indexedDBStorage: StateStorage = { getItem: async (name: string): Promise => { - if (typeof localStorage === 'undefined') return null - - if (migrationPromiseInternal) { - await migrationPromiseInternal - } + if (typeof window === 'undefined') return null + await awaitMigration() try { const value = await get(name) @@ -67,7 +70,9 @@ export const indexedDBStorage: StateStorage = { }, setItem: async (name: string, value: string): Promise => { - if (typeof localStorage === 'undefined') return + if (typeof window === 'undefined') return + await awaitMigration() + try { await set(name, value) } catch (error) { @@ -76,7 +81,9 @@ export const indexedDBStorage: StateStorage = { }, removeItem: async (name: string): Promise => { - if (typeof localStorage === 'undefined') return + if (typeof window === 'undefined') return + await awaitMigration() + try { await del(name) } catch (error) { From 3f0e89647d6605a4e35f085f6cbc87bdec215b25 Mon Sep 17 00:00:00 2001 From: JaeHyung Jang Date: Tue, 26 May 2026 18:12:49 +0900 Subject: [PATCH 4/5] fix(undo-redo): clear IndexedDB key on sign-out `clearUserData` previously cleared `localStorage` only. After moving workflow-undo-redo persistence to IndexedDB, undo/redo history could survive across user sessions on the same device. Add `clearPersistedUndoRedo()` to the adapter and invoke it from `clearUserData` so sign-out removes the IndexedDB entry as well. The migration flag is left intact so the one-time migration does not re-run on the next sign-in. Signed-off-by: JaeHyung Jang --- apps/sim/stores/index.ts | 4 +++ apps/sim/stores/undo-redo/storage.test.ts | 30 +++++++++++++++++++++++ apps/sim/stores/undo-redo/storage.ts | 17 +++++++++++++ 3 files changed, 51 insertions(+) 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 index 13c708fc415..d4606741d01 100644 --- a/apps/sim/stores/undo-redo/storage.test.ts +++ b/apps/sim/stores/undo-redo/storage.test.ts @@ -154,4 +154,34 @@ describe('undo-redo IndexedDB storage adapter', () => { 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('swallows IndexedDB errors so sign-out does not crash', async () => { + const { clearPersistedUndoRedo, migrationReady } = await loadFreshModule() + await migrationReady + + idbDel.mockRejectedValueOnce(new Error('idb delete failed')) + await expect(clearPersistedUndoRedo()).resolves.toBeUndefined() + }) + }) }) diff --git a/apps/sim/stores/undo-redo/storage.ts b/apps/sim/stores/undo-redo/storage.ts index b4ad0226641..035e134dd06 100644 --- a/apps/sim/stores/undo-redo/storage.ts +++ b/apps/sim/stores/undo-redo/storage.ts @@ -55,6 +55,23 @@ async function awaitMigration(): Promise { } } +/** + * 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. + */ +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 }) + } +} + export const indexedDBStorage: StateStorage = { getItem: async (name: string): Promise => { if (typeof window === 'undefined') return null From 2a8bf5f45b6fba53d99d14c75acc73761a7c1ff3 Mon Sep 17 00:00:00 2001 From: Sprexatura Date: Tue, 26 May 2026 18:26:17 +0900 Subject: [PATCH 5/5] fix(undo-redo): close two more review findings - Block setItem/removeItem on the first getItem (hydration read) so a state change cannot persist an empty in-memory snapshot over the IndexedDB payload before zustand's persist middleware finishes rehydration. Without this guard, switching from synchronous localStorage to async IndexedDB introduced a race that could wipe undo history after reload (Cursor Bugbot, medium). - Propagate IndexedDB errors from clearPersistedUndoRedo so a delete failure surfaces to clearUserData's outer try/catch instead of silently leaving a previous user's payload behind (Cursor Bugbot, medium). - Add tests for the hydration-read ordering and the error propagation. Signed-off-by: JaeHyung Jang --- apps/sim/stores/undo-redo/storage.test.ts | 33 +++++++++++++++- apps/sim/stores/undo-redo/storage.ts | 46 +++++++++++++++++++---- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/apps/sim/stores/undo-redo/storage.test.ts b/apps/sim/stores/undo-redo/storage.test.ts index d4606741d01..5e5d6f0b30a 100644 --- a/apps/sim/stores/undo-redo/storage.test.ts +++ b/apps/sim/stores/undo-redo/storage.test.ts @@ -176,12 +176,41 @@ describe('undo-redo IndexedDB storage adapter', () => { expect(idbStore.get(MIGRATION_KEY)).toBe(true) }) - it('swallows IndexedDB errors so sign-out does not crash', async () => { + 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()).resolves.toBeUndefined() + 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 index 035e134dd06..5c0cc9773bb 100644 --- a/apps/sim/stores/undo-redo/storage.ts +++ b/apps/sim/stores/undo-redo/storage.ts @@ -9,6 +9,14 @@ 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. @@ -55,11 +63,23 @@ async function awaitMigration(): Promise { } } +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. + * 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 @@ -69,6 +89,7 @@ export async function clearPersistedUndoRedo(): Promise { await del(STORE_KEY) } catch (error) { logger.warn('Failed to clear persisted undo-redo', { error }) + throw error } } @@ -77,18 +98,28 @@ export const indexedDBStorage: StateStorage = { if (typeof window === 'undefined') return null await awaitMigration() - try { - const value = await get(name) - return value ?? null - } catch (error) { - logger.warn('IndexedDB read failed', { name, error }) - return null + 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) @@ -100,6 +131,7 @@ export const indexedDBStorage: StateStorage = { removeItem: async (name: string): Promise => { if (typeof window === 'undefined') return await awaitMigration() + await awaitHydrationRead() try { await del(name)