Fix 50k-row edit performance: replace deep-watch undo/persistence with explicit dirty-marking

The debounced undo-history and localStorage persistence were driven by
watch(() => doc, ..., { deep: true }) -- Vue's deep watch re-traverses
the entire reactive object graph on every single mutation just to
re-establish tracking, an O(document size) cost per edit regardless of
what actually changed. Measured on a 50k-row table: one setCellInput
call went from ~450ms to <1ms with the deep watch disabled.

Both watches are replaced by scheduleSave()/markDirty(), called
explicitly by every content-mutating store function (mirroring the
existing bump()/recalcTick convention), with pure selection/navigation
functions calling scheduleSave() only so cursor movement still never
becomes an undo step. applyDocumentState (undo/redo/replaceDocument)
schedules persistence but never marks history dirty, since those
callers already reconcile history state themselves -- this also makes
the old applyingHistory guard flag dead code, now removed.

setCellInput gets an O(1) same-value no-op guard (the highest-frequency
mutator) so retyping an unchanged value creates no history noise; the
remaining no-op safety net (a real fingerprint diff) still lives in
commitHistoryStep, now running once per debounced burst instead of per
keystroke.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-09 22:20:27 +02:00
co-authored by Claude Sonnet 5
parent 47c99bbeb6
commit 1c5e57ceb4
3 changed files with 212 additions and 73 deletions
+28
View File
@@ -2241,6 +2241,34 @@ describe('undo / redo', () => {
expect(s.cellFormat(id, 0, 0)).toEqual({});
});
it('re-committing a cell\'s unchanged value is a true no-op -- no step, redo survives', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
vi.advanceTimersByTime(600); // table creation is its own step
setCell(s, name, 'A1', '1');
vi.advanceTimersByTime(600);
setCell(s, name, 'A1', '2');
vi.advanceTimersByTime(600);
s.undo(); // undoes '2' -> back to '1'
expect(s.cellInput(id, 0, 0)).toBe('1');
expect(s.canRedo.value).toBe(true);
// F2, Enter without changing anything -- a true no-op, must not clear redo
s.setCellInput(id, 0, 0, '1');
expect(s.canRedo.value).toBe(true);
s.redo();
expect(s.cellInput(id, 0, 0)).toBe('2');
// clearing an already-empty cell is likewise a no-op -- creates no step
s.setCellInput(id, 5, 5, '');
vi.advanceTimersByTime(600);
s.undo(); // must undo the '1' -> '2' edit directly, not some no-op clear step
expect(s.cellInput(id, 0, 0)).toBe('1');
expect(s.cellInput(id, 5, 5)).toBe('');
});
it('resetToSeed starts a fresh history -- the prior document is not undoable back', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);