Skip to content

fix(undo-redo): bound persisted history by size, not just operation count - #6313

Open
rohitsudhakar1 wants to merge 2 commits into
simstudioai:mainfrom
rohitsudhakar1:fix-4737-undo-redo-persist-budget
Open

fix(undo-redo): bound persisted history by size, not just operation count#6313
rohitsudhakar1 wants to merge 2 commits into
simstudioai:mainfrom
rohitsudhakar1:fix-4737-undo-redo-persist-budget

Conversation

@rohitsudhakar1

Copy link
Copy Markdown

Root cause

workflow-undo-redo persists operation snapshots to localStorage, but the eviction policy bounds them by count, not by size: MAX_STACKS = 5 x DEFAULT_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 setItem from any other persisted Zustand store throws QuotaExceededError. That is why the error surfaces from unrelated stores such as notification-storage or panel-editor-state — whichever one happens to write next is the victim, not the culprit.

The existing safeStorageAdapter catches QuotaExceededError on 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 by createdAt across all stacks until the payload fits, and drops any stack left empty to reclaim its overhead.
  • Eviction runs in two passes: a bulk pass that subtracts each entry's own serialized length (avoiding an O(n^2) re-serialization of megabytes on every drop), then an exact verification pass that keeps dropping the oldest survivors until the invariant actually holds. In practice the second pass runs zero or one times.
  • The input state is never mutated. Only the persisted copy is trimmed.

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:

  • Reproduction: heavy editing with large block snapshots keeps the persisted payload within a sane byte budget.
  • Recent history stays usable after trimming (newest operations survive).
  • trimPersistedStateToBudget returns state unchanged when already within budget.
  • Evicts oldest-first across stacks until under budget.
  • Does not mutate the input state.
  • Drops stacks emptied by eviction.

vitest run stores/undo-redo/store.test.ts — 45/45 pass (39 existing, unchanged).
biome check clean.

Typecheck note: a full-repo tsc --noEmit exhausts 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

…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
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 6, 2026 5:45am

Request Review

@cursor

cursor Bot commented Aug 6, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes persistence semantics (trimmed history on reload) and shared localStorage budgeting; logic is isolated to undo-redo persist but affects cross-store quota behavior.

Overview
Caps workflow-undo-redo localStorage writes at MAX_PERSISTED_BYTES (2 MB) so large block snapshots cannot monopolize the origin’s shared storage budget and trigger QuotaExceededError in unrelated persisted stores.

trimPersistedStateToBudget() runs in partialize before each persist: it copies stacks and evicts history entries furthest from the next undo/redo (front of each stack), with a bulk estimate pass plus an exact size check. Empty stacks are removed. In-memory stacks are not trimmed, so the current session keeps full undo capacity; after reload, older history beyond the budget is gone.

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-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR caps persisted undo/redo history at approximately 2 MB by trimming an immutable persistence copy while retaining full in-memory history.

  • Adds global oldest-first eviction across persisted workflow stacks.
  • Adds exact post-eviction size verification and removes empty stacks.
  • Adds tests for budget enforcement, survivor ordering, immutability, and empty-stack cleanup.

Confidence Score: 4/5

The 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

Important Files Changed

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]
Loading

Reviews (1): Last reviewed commit: "fix(undo-redo): bound persisted history ..." | Re-trigger Greptile

Comment thread apps/sim/stores/undo-redo/store.ts Outdated
Comment on lines +166 to +171
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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)

Comment thread apps/sim/stores/undo-redo/store.test.ts Outdated
stacks: {
a: {
undo: [entryOfSize(1, 5000), entryOfSize(4, 5000)],
redo: [entryOfSize(2, 5000)],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

Comment thread apps/sim/stores/undo-redo/store.ts Outdated
Comment on lines +165 to +166
// Every removable entry, oldest first.
const removable = Object.entries(stacks)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread apps/sim/stores/undo-redo/store.ts Outdated
…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.
@rohitsudhakar1

Copy link
Copy Markdown
Author

Thanks, the P1 is real and I have fixed it in 20d7565.

You are right that eviction was ordered purely by createdAt, which is correct for undo but inverted for redo. Both stacks are consumed from the end (undo() takes undo[undo.length - 1], redo() takes redo[redo.length - 1]), so the next redo is the array's oldest entry. Trimming oldest-first removed exactly the operation redo needed next while keeping the later ones that depend on it, so after a reload redo could skip a step or replay against the wrong graph.

The fix orders by depth from the end of each array, so eviction always drops the front, which is what slice(-capacity) already does for capacity-based eviction. createdAt now only breaks ties. Added a regression test that builds a populated redo stack and asserts the next redo operation survives while the front is evicted.

On the test fixtures: also fixed. They now type against PersistedUndoRedoState and OperationEntry rather than any, with a single cast confined to the entry factory since the fixtures only need createdAt and a size.

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 apps/sim/.cursorrules in the repo. The rules under .cursor/rules/ that I did find are scoped to landing and docs copy rather than store code, so if there is a documentation convention I am still missing, point me at it and I will match it.

46/46 tests pass, biome check clean, and the two changed files typecheck cleanly in isolation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(undo-redo): QuotaExceededError when localStorage cap is exhausted by undo/redo snapshots

1 participant