Skip to content

Commit 73e563b

Browse files
committed
fix(undo-redo): persist unified stack to IndexedDB with throttled writes
Code-field undo frames are large and accumulate quickly; keeping them in the synchronous localStorage-backed stack risked main-thread jank on every keystroke and the ~5MB quota. Move the unified undo store to async IndexedDB (idb-keyval), matching how the old separate code stack was persisted. - Replace the localStorage persist adapter with a throttled IndexedDB adapter (coalesces a burst of writes into one transaction; flushes on tab hide) - Delete the now-orphaned code-storage adapter
1 parent e1db23a commit 73e563b

3 files changed

Lines changed: 70 additions & 72 deletions

File tree

apps/sim/stores/undo-redo/code-storage.ts

Lines changed: 0 additions & 36 deletions
This file was deleted.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { createLogger } from '@sim/logger'
2+
import { del, get, set } from 'idb-keyval'
3+
import type { StateStorage } from 'zustand/middleware'
4+
5+
const logger = createLogger('UndoRedoStorage')
6+
7+
/** A burst of edits within this window is persisted as a single IndexedDB write. */
8+
const PERSIST_THROTTLE_MS = 1000
9+
10+
/**
11+
* IndexedDB-backed persistence for the undo/redo store. Unlike localStorage it is
12+
* asynchronous (never blocks the main thread on write) and has a large quota, so it
13+
* tolerates the volume of large code-field undo frames. Writes are throttled so a
14+
* burst of keystrokes produces a single transaction rather than one write per change.
15+
*/
16+
function createThrottledIndexedDbStorage(): StateStorage {
17+
const pending = new Map<string, string>()
18+
let timer: ReturnType<typeof setTimeout> | null = null
19+
20+
const flush = (): void => {
21+
timer = null
22+
const writes = [...pending]
23+
pending.clear()
24+
for (const [name, value] of writes) {
25+
void set(name, value).catch((error) => logger.warn('IndexedDB write failed', { name, error }))
26+
}
27+
}
28+
29+
if (typeof window !== 'undefined') {
30+
// Persist any pending write before the tab is hidden or closed so it isn't lost.
31+
const flushOnHide = () => {
32+
if (document.visibilityState === 'hidden') flush()
33+
}
34+
window.addEventListener('pagehide', flush)
35+
document.addEventListener('visibilitychange', flushOnHide)
36+
}
37+
38+
return {
39+
getItem: async (name: string): Promise<string | null> => {
40+
if (typeof window === 'undefined') return null
41+
if (pending.has(name)) return pending.get(name) ?? null
42+
try {
43+
return (await get<string>(name)) ?? null
44+
} catch (error) {
45+
logger.warn('IndexedDB read failed', { name, error })
46+
return null
47+
}
48+
},
49+
50+
setItem: (name: string, value: string): void => {
51+
if (typeof window === 'undefined') return
52+
pending.set(name, value)
53+
if (!timer) timer = setTimeout(flush, PERSIST_THROTTLE_MS)
54+
},
55+
56+
removeItem: async (name: string): Promise<void> => {
57+
if (typeof window === 'undefined') return
58+
pending.delete(name)
59+
try {
60+
await del(name)
61+
} catch (error) {
62+
logger.warn('IndexedDB delete failed', { name, error })
63+
}
64+
},
65+
}
66+
}
67+
68+
export const undoRedoStorage: StateStorage = createThrottledIndexedDbStorage()

apps/sim/stores/undo-redo/store.ts

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isEqual } from 'es-toolkit'
44
import type { Edge } from 'reactflow'
55
import { create } from 'zustand'
66
import { createJSONStorage, persist } from 'zustand/middleware'
7+
import { undoRedoStorage } from '@/stores/undo-redo/storage'
78
import type {
89
BatchAddBlocksOperation,
910
BatchAddEdgesOperation,
@@ -136,41 +137,6 @@ function getStackKey(workflowId: string, userId: string): string {
136137
return `${workflowId}:${userId}`
137138
}
138139

139-
/**
140-
* Custom storage adapter for Zustand's persist middleware.
141-
* We need this wrapper to gracefully handle 'QuotaExceededError' when localStorage is full,
142-
* Without this, the default storage engine would throw and crash the application.
143-
* and to properly handle SSR/Node.js environments.
144-
*/
145-
const safeStorageAdapter = {
146-
getItem: (name: string): string | null => {
147-
if (typeof localStorage === 'undefined') return null
148-
try {
149-
return localStorage.getItem(name)
150-
} catch (e) {
151-
logger.warn('Failed to read from localStorage', e)
152-
return null
153-
}
154-
},
155-
setItem: (name: string, value: string): void => {
156-
if (typeof localStorage === 'undefined') return
157-
try {
158-
localStorage.setItem(name, value)
159-
} catch (e) {
160-
// Log warning but don't crash - this handles QuotaExceededError
161-
logger.warn('Failed to save to localStorage', e)
162-
}
163-
},
164-
removeItem: (name: string): void => {
165-
if (typeof localStorage === 'undefined') return
166-
try {
167-
localStorage.removeItem(name)
168-
} catch (e) {
169-
logger.warn('Failed to remove from localStorage', e)
170-
}
171-
},
172-
}
173-
174140
function isOperationApplicable(
175141
operation: Operation,
176142
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
@@ -648,7 +614,7 @@ export const useUndoRedoStore = create<UndoRedoState>()(
648614
}),
649615
{
650616
name: 'workflow-undo-redo',
651-
storage: createJSONStorage(() => safeStorageAdapter),
617+
storage: createJSONStorage(() => undoRedoStorage),
652618
partialize: (state) => ({
653619
stacks: state.stacks,
654620
capacity: state.capacity,

0 commit comments

Comments
 (0)