Skip to content

Commit 7e769c8

Browse files
fix(undo-redo): bound persisted history by size, not just operation count
The undo-redo store persists operation snapshots to localStorage bounded only by MAX_STACKS (5) x DEFAULT_CAPACITY (100) operations. A single operation can carry several KB of block state, so active editing lets the persisted history grow to multiple megabytes and exhaust the origin's shared ~5 MB localStorage budget. Once that budget is gone, the next setItem from any other persisted store throws QuotaExceededError, so the store that surfaces the error is the victim rather than the culprit. The existing safeStorageAdapter only stops this store's own writes from throwing; it does nothing to stop the store from starving its siblings. Cap the serialized footprint at MAX_PERSISTED_BYTES and trim inside partialize, evicting the oldest operations across stacks and dropping any stack left empty. Only the persisted copy is trimmed, so the in-memory history is untouched and the current session stays fully undoable. Tests cover the reproduction (heavy editing stays within budget, newest operations survive) and the trim function directly (no-op when under budget, oldest-first eviction, input not mutated, empty stacks dropped). Fixes #4737
1 parent 54a3262 commit 7e769c8

2 files changed

Lines changed: 221 additions & 5 deletions

File tree

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

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import {
2121
createUpdateParentEntry,
2222
} from '@sim/testing'
2323
import { beforeEach, describe, expect, it } from 'vitest'
24-
import { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from '@/stores/undo-redo/store'
24+
import {
25+
runWithUndoRedoRecordingSuspended,
26+
trimPersistedStateToBudget,
27+
useUndoRedoStore,
28+
} from '@/stores/undo-redo/store'
2529
import type { UpdateParentOperation } from '@/stores/undo-redo/types'
2630

2731
describe('useUndoRedoStore', () => {
@@ -762,4 +766,131 @@ describe('useUndoRedoStore', () => {
762766
expect(parentEntry?.operation.type).toBe('update-parent')
763767
})
764768
})
769+
770+
describe('persisted size budget (issue #4737)', () => {
771+
// Build an entry whose operation + inverse each carry a large block snapshot,
772+
// approximating the multi-KB snapshots real editing produces.
773+
const bigEntry = (index: number, approxBytes = 15_000) => {
774+
const snapshot = {
775+
id: `block-${index}`,
776+
type: 'action',
777+
name: `Block ${index}`,
778+
position: { x: index, y: index },
779+
subBlocks: { note: { id: 'note', type: 'long-input', value: 'x'.repeat(approxBytes) } },
780+
}
781+
return createRemoveBlockEntry(`block-${index}`, snapshot, {
782+
workflowId,
783+
userId,
784+
createdAt: index,
785+
})
786+
}
787+
788+
it('keeps the persisted payload within a sane byte budget under heavy editing', () => {
789+
const { push } = useUndoRedoStore.getState()
790+
791+
// A single stack at default capacity (100) of ~30 KB entries serializes to
792+
// ~3 MB — enough on its own to exhaust the origin's shared ~5 MB budget and
793+
// make an unrelated persisted store throw QuotaExceededError on its next
794+
// write. The persisted footprint must stay bounded regardless of op count.
795+
for (let i = 0; i < 100; i++) {
796+
push(workflowId, userId, bigEntry(i))
797+
}
798+
799+
const persisted = global.localStorage.getItem('workflow-undo-redo')
800+
expect(persisted).not.toBeNull()
801+
802+
// A sane share of the ~5 MB origin budget, leaving room for sibling stores.
803+
const SANE_BUDGET_BYTES = 2 * 1024 * 1024
804+
expect(persisted!.length).toBeLessThanOrEqual(SANE_BUDGET_BYTES)
805+
})
806+
807+
it('keeps recent history usable after trimming (newest ops survive)', () => {
808+
const { push, getStackSizes } = useUndoRedoStore.getState()
809+
810+
for (let i = 0; i < 100; i++) {
811+
push(workflowId, userId, bigEntry(i))
812+
}
813+
814+
// Trimming targets storage only; the in-memory stack stays fully intact, so
815+
// the current session remains undoable to its capacity.
816+
expect(getStackSizes(workflowId, userId).undoSize).toBe(100)
817+
818+
const persisted = JSON.parse(global.localStorage.getItem('workflow-undo-redo')!)
819+
const undo = persisted.state.stacks[`${workflowId}:${userId}`].undo
820+
// Some history is persisted, and it's the newest slice (oldest evicted first).
821+
expect(undo.length).toBeGreaterThan(0)
822+
expect(undo.length).toBeLessThan(100)
823+
const createdAts = undo.map((e: { createdAt: number }) => e.createdAt)
824+
expect(createdAts[createdAts.length - 1]).toBe(99)
825+
expect(Math.min(...createdAts)).toBeGreaterThan(0)
826+
})
827+
})
828+
829+
describe('trimPersistedStateToBudget', () => {
830+
const entryOfSize = (createdAt: number, chars: number) => ({
831+
id: `e-${createdAt}`,
832+
createdAt,
833+
operation: { id: `op-${createdAt}`, data: { blob: 'x'.repeat(chars) } },
834+
inverse: { id: `inv-${createdAt}`, data: {} },
835+
})
836+
837+
it('returns the state unchanged when already within budget', () => {
838+
const state = {
839+
capacity: 100,
840+
stacks: { 'wf:user': { undo: [entryOfSize(1, 10)], redo: [], lastUpdated: 1 } },
841+
} as any
842+
expect(trimPersistedStateToBudget(state, 1024 * 1024)).toBe(state)
843+
})
844+
845+
it('evicts oldest-first across stacks until under budget', () => {
846+
const state = {
847+
capacity: 100,
848+
stacks: {
849+
a: {
850+
undo: [entryOfSize(1, 5000), entryOfSize(4, 5000)],
851+
redo: [entryOfSize(2, 5000)],
852+
lastUpdated: 4,
853+
},
854+
b: { undo: [entryOfSize(3, 5000)], redo: [], lastUpdated: 3 },
855+
},
856+
} as any
857+
858+
const budget = 12_000
859+
const trimmed = trimPersistedStateToBudget(state, budget)
860+
861+
expect(JSON.stringify(trimmed).length).toBeLessThanOrEqual(budget)
862+
// The oldest entries (createdAt 1, 2) are gone; the newest (3, 4) survive.
863+
const survivors = Object.values(trimmed.stacks)
864+
.flatMap((s: any) => [...s.undo, ...s.redo])
865+
.map((e: any) => e.createdAt)
866+
.sort((x, y) => x - y)
867+
expect(survivors).toEqual([3, 4])
868+
})
869+
870+
it('does not mutate the input state', () => {
871+
const state = {
872+
capacity: 100,
873+
stacks: {
874+
a: { undo: [entryOfSize(1, 5000), entryOfSize(2, 5000)], redo: [], lastUpdated: 2 },
875+
},
876+
} as any
877+
878+
trimPersistedStateToBudget(state, 6000)
879+
expect(state.stacks.a.undo).toHaveLength(2)
880+
})
881+
882+
it('drops stacks emptied by eviction', () => {
883+
const state = {
884+
capacity: 100,
885+
stacks: {
886+
old: { undo: [entryOfSize(1, 8000)], redo: [], lastUpdated: 1 },
887+
fresh: { undo: [entryOfSize(2, 100)], redo: [], lastUpdated: 2 },
888+
},
889+
} as any
890+
891+
const trimmed = trimPersistedStateToBudget(state, 4000)
892+
expect(trimmed.stacks.old).toBeUndefined()
893+
expect(trimmed.stacks.fresh).toBeDefined()
894+
})
895+
})
765896
})

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

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@ const logger = createLogger('UndoRedoStore')
2020
const DEFAULT_CAPACITY = 100
2121
const MAX_STACKS = 5
2222

23+
/**
24+
* Upper bound on the *serialized* size of the persisted history.
25+
*
26+
* DEFAULT_CAPACITY and MAX_STACKS bound how many operations are retained, not how
27+
* many bytes they occupy — and a single operation can carry several KB of block
28+
* snapshots. Left unbounded by size, the history can still grow to multiple
29+
* megabytes and exhaust the origin's shared ~5 MB localStorage budget; the
30+
* resulting QuotaExceededError then surfaces in whichever *other* persisted store
31+
* writes next (notification-storage, panel state, …), so the store that throws is
32+
* the victim, not the culprit. Capping the footprint here keeps undo/redo from
33+
* starving its siblings. Only the persisted copy is trimmed (oldest operations
34+
* first); the in-memory history is left intact, so the current session stays
35+
* fully undoable.
36+
*/
37+
// ~2 MB: a conservative minority of the typical ~5 MB origin budget, so the
38+
// majority stays available to sibling persisted stores.
39+
export const MAX_PERSISTED_BYTES = 2 * 1024 * 1024
40+
2341
let recordingSuspendDepth = 0
2442

2543
function isRecordingSuspended(): boolean {
@@ -120,6 +138,72 @@ function isOperationApplicable(
120138
}
121139
}
122140

141+
type PersistedUndoRedoState = Pick<UndoRedoState, 'stacks' | 'capacity'>
142+
143+
function serializedLength(value: unknown): number {
144+
return JSON.stringify(value)?.length ?? 0
145+
}
146+
147+
/**
148+
* Returns a copy of the persisted state trimmed so its serialized size fits
149+
* `maxBytes`, evicting the oldest operations (by `createdAt`) across every stack
150+
* first and dropping any stack left empty. The input is never mutated, so the
151+
* live in-memory history is unaffected — only what gets written to storage shrinks.
152+
*/
153+
export function trimPersistedStateToBudget(
154+
state: PersistedUndoRedoState,
155+
maxBytes: number
156+
): PersistedUndoRedoState {
157+
if (serializedLength(state) <= maxBytes) return state
158+
159+
// Clone stacks (and their arrays) so eviction never touches the live state.
160+
const stacks: PersistedUndoRedoState['stacks'] = {}
161+
for (const [key, stack] of Object.entries(state.stacks)) {
162+
stacks[key] = { ...stack, undo: [...stack.undo], redo: [...stack.redo] }
163+
}
164+
165+
// Every removable entry, oldest first.
166+
const removable = Object.entries(stacks)
167+
.flatMap(([key, stack]) => [
168+
...stack.undo.map((entry) => ({ key, list: 'undo' as const, entry })),
169+
...stack.redo.map((entry) => ({ key, list: 'redo' as const, entry })),
170+
])
171+
.sort((a, b) => a.entry.createdAt - b.entry.createdAt)
172+
173+
const drop = ({ key, list, entry }: (typeof removable)[number]): void => {
174+
const stack = stacks[key]
175+
if (!stack) return
176+
const arr = stack[list]
177+
const idx = arr.indexOf(entry)
178+
if (idx !== -1) arr.splice(idx, 1)
179+
// Reclaim the key/overhead of a stack emptied by eviction.
180+
if (stack.undo.length === 0 && stack.redo.length === 0) delete stacks[key]
181+
}
182+
183+
// Pass 1: estimate-based bulk eviction. Subtracting each entry's own serialized
184+
// length (plus a separating comma) avoids re-serializing the whole payload on
185+
// every eviction, which would be O(n²) over the megabytes involved.
186+
let approxBytes = serializedLength(state)
187+
let i = 0
188+
for (; i < removable.length && approxBytes > maxBytes; i++) {
189+
approxBytes -= serializedLength(removable[i].entry) + 1
190+
drop(removable[i])
191+
}
192+
193+
// Pass 2: the estimate can undershoot the true reduction (structural overhead it
194+
// doesn't attribute to entries), so verify exactly and keep dropping the oldest
195+
// survivors until the invariant holds. In practice this runs zero or one times.
196+
while (
197+
i < removable.length &&
198+
serializedLength({ stacks, capacity: state.capacity }) > maxBytes
199+
) {
200+
drop(removable[i])
201+
i++
202+
}
203+
204+
return { stacks, capacity: state.capacity }
205+
}
206+
123207
export const useUndoRedoStore = create<UndoRedoState>()(
124208
persist(
125209
(set, get) => ({
@@ -502,10 +586,11 @@ export const useUndoRedoStore = create<UndoRedoState>()(
502586
{
503587
name: 'workflow-undo-redo',
504588
storage: createJSONStorage(() => safeStorageAdapter),
505-
partialize: (state) => ({
506-
stacks: state.stacks,
507-
capacity: state.capacity,
508-
}),
589+
partialize: (state) =>
590+
trimPersistedStateToBudget(
591+
{ stacks: state.stacks, capacity: state.capacity },
592+
MAX_PERSISTED_BYTES
593+
),
509594
}
510595
)
511596
)

0 commit comments

Comments
 (0)