fix(undo-redo): bound persisted history by size, not just operation count - #6313
fix(undo-redo): bound persisted history by size, not just operation count#6313rohitsudhakar1 wants to merge 2 commits into
Conversation
…ount 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 simstudioai#4737
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview
Adds regression tests for heavy editing, newest-op survival, redo ordering, immutability, and emptied-stack cleanup (issue #4737). Reviewed by Cursor Bugbot for commit 20d7565. Bugbot is set up for automated code reviews on this repo. Configure here. |
Greptile SummaryThe PR caps persisted undo/redo history at approximately 2 MB by trimming an immutable persistence copy while retaining full in-memory history.
Confidence Score: 4/5The redo-eviction ordering defect should be fixed before merging because persisted redo history can replay operations out of sequence after reload. The trimmer treats redo entries as ordinary chronological history even though redo consumes them in the opposite structural order, allowing the next required redo operation to be removed while dependent later entries survive. Files Needing Attention: apps/sim/stores/undo-redo/store.ts, apps/sim/stores/undo-redo/store.test.ts
|
| Filename | Overview |
|---|---|
| apps/sim/stores/undo-redo/store.ts | Adds size-bounded persistence, but chronological eviction from redo can break its required replay order after reload. |
| apps/sim/stores/undo-redo/store.test.ts | Adds broad trimming coverage, but does not exercise populated redo stacks and introduces prohibited any assertions and comment forms. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[In-memory undo/redo state] --> B[Zustand partialize]
B --> C{Serialized state over budget?}
C -- No --> D[Persist unchanged copy]
C -- Yes --> E[Clone stacks]
E --> F[Sort undo and redo entries by createdAt]
F --> G[Evict oldest entries]
G --> H[Exact size verification]
H --> D
D --> I[localStorage]
I --> J[Rehydrate after reload]
Reviews (1): Last reviewed commit: "fix(undo-redo): bound persisted history ..." | Re-trigger Greptile
| const removable = Object.entries(stacks) | ||
| .flatMap(([key, stack]) => [ | ||
| ...stack.undo.map((entry) => ({ key, list: 'undo' as const, entry })), | ||
| ...stack.redo.map((entry) => ({ key, list: 'redo' as const, entry })), | ||
| ]) | ||
| .sort((a, b) => a.entry.createdAt - b.entry.createdAt) |
There was a problem hiding this comment.
Redo eviction breaks replay order
When a persisted stack contains multiple redo entries and exceeds the size budget, sorting those entries by ascending createdAt can remove the next operation that redo must replay while retaining later dependent operations, causing redo after reload to skip work, become a no-op, or produce incorrect workflow state.
Knowledge Base Used: Workspace Frontend (Workflow Editor UI)
| stacks: { | ||
| a: { | ||
| undo: [entryOfSize(1, 5000), entryOfSize(4, 5000)], | ||
| redo: [entryOfSize(2, 5000)], |
There was a problem hiding this comment.
Test fixtures bypass state types
The new trimming tests use any for persisted-state fixtures and survivor callbacks, bypassing the production persistence types and allowing these tests to silently drift from the state contract.
Context Used: TypeScript conventions and type safety (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| // Every removable entry, oldest first. | ||
| const removable = Object.entries(stacks) |
There was a problem hiding this comment.
Comments bypass required TSDoc format
The trimming implementation and its tests add ordinary // documentation comments throughout the changed hunks, so this new documentation does not follow the repository's required TSDoc format or integrate consistently with its documentation tooling.
Context Used: apps/sim/.cursorrules (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7e769c8. Configure here.
…trimming Review caught that ordering eviction purely by createdAt is correct for the undo stack but inverted for redo. Both stacks are consumed from their end, so redo's next entry is its oldest, and trimming oldest-first removed the operation redo needed next while keeping the later ones that depend on it. After a reload redo could skip a step or replay against the wrong graph. Evict by depth from the end of each array instead, which drops the front first and matches the capacity policy that keeps the tail via slice(-capacity). createdAt now only breaks ties. Also type the test fixtures against PersistedUndoRedoState and OperationEntry instead of any, and add a regression test asserting the next redo operation survives while the front of the redo stack is evicted.
|
Thanks, the P1 is real and I have fixed it in 20d7565. You are right that eviction was ordered purely by The fix orders by depth from the end of each array, so eviction always drops the front, which is what On the test fixtures: also fixed. They now type against On the TSDoc point: I have moved the explanation into the exported function's TSDoc and converted the remaining inline notes to block comments. One thing worth flagging, I could not find 46/46 tests pass, |

Root cause
workflow-undo-redopersists operation snapshots tolocalStorage, but the eviction policy bounds them by count, not by size:MAX_STACKS = 5xDEFAULT_CAPACITY = 100= 500 operations. A single operation can carry several KB of block state (an agent block with a large system prompt, for example), so active editing across a few workflows lets the persisted history grow to multiple megabytes and consume most of the origin's shared ~5 MB budget.Once that budget is exhausted, the next
setItemfrom any other persisted Zustand store throwsQuotaExceededError. That is why the error surfaces from unrelated stores such asnotification-storageorpanel-editor-state— whichever one happens to write next is the victim, not the culprit.The existing
safeStorageAdaptercatchesQuotaExceededErroron this store's own writes, but that only stops this store from throwing. It does nothing to stop it from starving its siblings, which is the failure users actually see.Fix
Cap the serialized footprint of the persisted state and trim inside
partialize:MAX_PERSISTED_BYTES(2 MB) bounds what gets written to storage.trimPersistedStateToBudget()evicts the oldest operations bycreatedAtacross all stacks until the payload fits, and drops any stack left empty to reclaim its overhead.On the 2 MB figure: it is a deliberately conservative share of the ~5 MB origin budget, chosen to leave roughly 3 MB for every other persisted store, rather than a measured optimum. It is a single exported constant and easy to tune if you would prefer a different split.
Behavior change worth calling out: because only the persisted copy is trimmed, the in-memory history is untouched and the current session remains fully undoable. A user who reloads will find that older undo history beyond the budget is gone. That seemed clearly better than truncating history someone is actively using, but it is a real change and I would rather surface it than have it discovered later.
Tests
Six new tests in
stores/undo-redo/store.test.ts:trimPersistedStateToBudgetreturns state unchanged when already within budget.vitest run stores/undo-redo/store.test.ts— 45/45 pass (39 existing, unchanged).biome checkclean.Typecheck note: a full-repo
tsc --noEmitexhausts the heap on my machine, so I typechecked the two changed files and their transitive imports in isolation, which passes cleanly. I have not verified a full-repo typecheck locally.Fixes #4737