Skip to content

Commit 20d7565

Browse files
fix(undo-redo): evict furthest-from-use first so redo order survives 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.
1 parent 7e769c8 commit 20d7565

2 files changed

Lines changed: 96 additions & 59 deletions

File tree

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

Lines changed: 55 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ import {
2121
createUpdateParentEntry,
2222
} from '@sim/testing'
2323
import { beforeEach, describe, expect, it } from 'vitest'
24+
import type { PersistedUndoRedoState } from '@/stores/undo-redo/store'
2425
import {
2526
runWithUndoRedoRecordingSuspended,
2627
trimPersistedStateToBudget,
2728
useUndoRedoStore,
2829
} from '@/stores/undo-redo/store'
29-
import type { UpdateParentOperation } from '@/stores/undo-redo/types'
30+
import type { OperationEntry, UpdateParentOperation } from '@/stores/undo-redo/types'
3031

3132
describe('useUndoRedoStore', () => {
3233
const workflowId = 'wf-test'
@@ -827,66 +828,80 @@ describe('useUndoRedoStore', () => {
827828
})
828829

829830
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: {} },
831+
const entryOfSize = (createdAt: number, chars: number): OperationEntry =>
832+
({
833+
id: `e-${createdAt}`,
834+
createdAt,
835+
operation: { id: `op-${createdAt}`, data: { blob: 'x'.repeat(chars) } },
836+
inverse: { id: `inv-${createdAt}`, data: {} },
837+
}) as unknown as OperationEntry
838+
839+
const persisted = (stacks: PersistedUndoRedoState['stacks']): PersistedUndoRedoState => ({
840+
capacity: 100,
841+
stacks,
835842
})
836843

844+
const createdAtsOf = (state: PersistedUndoRedoState): number[] =>
845+
Object.values(state.stacks)
846+
.flatMap((stack) => [...stack.undo, ...stack.redo])
847+
.map((entry) => entry.createdAt)
848+
.sort((x, y) => x - y)
849+
837850
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
851+
const state = persisted({
852+
'wf:user': { undo: [entryOfSize(1, 10)], redo: [], lastUpdated: 1 },
853+
})
842854
expect(trimPersistedStateToBudget(state, 1024 * 1024)).toBe(state)
843855
})
844856

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 },
857+
it('evicts the entries furthest from the next use until under budget', () => {
858+
const state = persisted({
859+
a: {
860+
undo: [entryOfSize(1, 5000), entryOfSize(4, 5000)],
861+
redo: [entryOfSize(2, 5000)],
862+
lastUpdated: 4,
855863
},
856-
} as any
864+
b: { undo: [entryOfSize(3, 5000)], redo: [], lastUpdated: 3 },
865+
})
857866

858867
const budget = 12_000
859868
const trimmed = trimPersistedStateToBudget(state, budget)
860869

861870
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])
871+
expect(createdAtsOf(trimmed)).toEqual([3, 4])
868872
})
869873

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 },
874+
it('keeps the next redo operation and evicts from the front of the redo stack', () => {
875+
// redo is replayed from the end, so redo[length - 1] (createdAt 1) is next.
876+
const state = persisted({
877+
a: {
878+
undo: [],
879+
redo: [entryOfSize(3, 5000), entryOfSize(2, 5000), entryOfSize(1, 5000)],
880+
lastUpdated: 3,
875881
},
876-
} as any
882+
})
883+
884+
const trimmed = trimPersistedStateToBudget(state, 11_000)
885+
const redo = trimmed.stacks.a.redo
886+
887+
expect(redo.map((entry) => entry.createdAt)).toEqual([2, 1])
888+
expect(redo[redo.length - 1].createdAt).toBe(1)
889+
})
890+
891+
it('does not mutate the input state', () => {
892+
const state = persisted({
893+
a: { undo: [entryOfSize(1, 5000), entryOfSize(2, 5000)], redo: [], lastUpdated: 2 },
894+
})
877895

878896
trimPersistedStateToBudget(state, 6000)
879897
expect(state.stacks.a.undo).toHaveLength(2)
880898
})
881899

882900
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
901+
const state = persisted({
902+
old: { undo: [entryOfSize(1, 8000)], redo: [], lastUpdated: 1 },
903+
fresh: { undo: [entryOfSize(2, 100)], redo: [], lastUpdated: 2 },
904+
})
890905

891906
const trimmed = trimPersistedStateToBudget(state, 4000)
892907
expect(trimmed.stacks.old).toBeUndefined()

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

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -138,61 +138,83 @@ function isOperationApplicable(
138138
}
139139
}
140140

141-
type PersistedUndoRedoState = Pick<UndoRedoState, 'stacks' | 'capacity'>
141+
/** The slice of {@link UndoRedoState} that `persist` writes to storage. */
142+
export type PersistedUndoRedoState = Pick<UndoRedoState, 'stacks' | 'capacity'>
142143

144+
/** Serialized length of `value`, or 0 when it is not serializable. */
143145
function serializedLength(value: unknown): number {
144146
return JSON.stringify(value)?.length ?? 0
145147
}
146148

147149
/**
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.
150+
* Trims a copy of the persisted state so its serialized size fits `maxBytes`.
151+
*
152+
* Both stacks are consumed from their end: `undo()` takes `undo[undo.length - 1]`
153+
* and `redo()` takes `redo[redo.length - 1]`. Eviction therefore removes entries
154+
* furthest from the next use first, which is the front of each array, matching the
155+
* capacity policy that keeps the tail via `slice(-capacity)`. Ordering purely by
156+
* `createdAt` would be correct for `undo` but inverted for `redo`, whose next entry
157+
* is its oldest, and would drop the operation redo needs next while keeping the
158+
* later ones that depend on it.
159+
*
160+
* Any stack emptied by eviction is removed so its key and overhead are reclaimed.
161+
* The input is never mutated, so the live in-memory history is unaffected and only
162+
* what gets written to storage shrinks.
163+
*
164+
* @param state - The persisted slice to trim. Left untouched.
165+
* @param maxBytes - Serialized-size ceiling for the returned state.
166+
* @returns `state` itself when already within budget, otherwise a trimmed copy.
152167
*/
153168
export function trimPersistedStateToBudget(
154169
state: PersistedUndoRedoState,
155170
maxBytes: number
156171
): PersistedUndoRedoState {
157172
if (serializedLength(state) <= maxBytes) return state
158173

159-
// Clone stacks (and their arrays) so eviction never touches the live state.
160174
const stacks: PersistedUndoRedoState['stacks'] = {}
161175
for (const [key, stack] of Object.entries(state.stacks)) {
162176
stacks[key] = { ...stack, undo: [...stack.undo], redo: [...stack.redo] }
163177
}
164178

165-
// Every removable entry, oldest first.
166179
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)
180+
.flatMap(([key, stack]) =>
181+
(['undo', 'redo'] as const).flatMap((list) =>
182+
stack[list].map((entry, index) => ({
183+
key,
184+
list,
185+
entry,
186+
depth: stack[list].length - 1 - index,
187+
}))
188+
)
189+
)
190+
.sort((a, b) => b.depth - a.depth || a.entry.createdAt - b.entry.createdAt)
172191

173192
const drop = ({ key, list, entry }: (typeof removable)[number]): void => {
174193
const stack = stacks[key]
175194
if (!stack) return
176195
const arr = stack[list]
177196
const idx = arr.indexOf(entry)
178197
if (idx !== -1) arr.splice(idx, 1)
179-
// Reclaim the key/overhead of a stack emptied by eviction.
180198
if (stack.undo.length === 0 && stack.redo.length === 0) delete stacks[key]
181199
}
182200

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.
201+
/*
202+
* Pass 1 evicts in bulk against an estimate. Subtracting each entry's own
203+
* serialized length (plus a separating comma) avoids re-serializing the whole
204+
* payload on every eviction, which would be O(n^2) over the megabytes involved.
205+
*/
186206
let approxBytes = serializedLength(state)
187207
let i = 0
188208
for (; i < removable.length && approxBytes > maxBytes; i++) {
189209
approxBytes -= serializedLength(removable[i].entry) + 1
190210
drop(removable[i])
191211
}
192212

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.
213+
/*
214+
* The estimate can undershoot the true reduction because it does not attribute
215+
* structural overhead to entries, so pass 2 verifies exactly and keeps dropping
216+
* survivors until the invariant holds. In practice it runs zero or one times.
217+
*/
196218
while (
197219
i < removable.length &&
198220
serializedLength({ stacks, capacity: state.capacity }) > maxBytes

0 commit comments

Comments
 (0)