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
+19
View File
@@ -148,6 +148,25 @@ wenn nichts zu tun ist); greift auch ohne Tabellenfokus, außer während eine
Texteingabe (Zell-Editor, Formelleiste, Blatt-Umbenennen, …) den nativen
Undo dieses Feldes benutzen soll.
**Performance bei großen Tabellen**: Undo-Tracking und die (debounced)
`localStorage`-Persistenz werden **nicht** über einen `watch(doc, ...,
{deep:true})` angestoßen — Vues Deep-Watch muss dafür bei *jeder* Mutation den
kompletten reaktiven Objektgraphen erneut durchlaufen, ein Aufwand
proportional zur Gesamtgröße des Dokuments statt zur eigentlichen Änderung
(gemessen: bei einer 50.000-Zeilen-Tabelle kostete ein einzelner
Zellen-Edit dadurch ~450 ms, unabhängig vom Umfang der Änderung). Stattdessen
ruft jede inhaltsverändernde Store-Funktion explizit `markDirty()` auf (nach
demselben Muster wie `bump()`/`recalcTick` für den Engine-Cache) — reine
Auswahl-/Navigations-Funktionen rufen nur `scheduleSave()` (Persistenz ohne
Undo-Schritt). Der Kompromiss: `markDirty()` prüft nicht mehr bei jeder
Mutation per Fingerprint-Vergleich, ob wirklich etwas geändert wurde (genau
dieser Vergleich bei jeder Mutation war der teure Teil) — dieses Sicherheitsnetz
bleibt in `commitHistoryStep()` erhalten, läuft aber nur noch einmal pro
debounced Schritt statt pro Mutation. `setCellInput` hat zusätzlich eine
eigene, günstige No-Op-Prüfung (unverändert übernommener Wert, z. B. `F2`,
`Enter` ohne Änderung), damit der häufigste Fall keinen Schritt erzeugt und
den Wiederholen-Ast nicht verwirft.
**In-Zellen-Editor**: das Eingabefeld liegt als Overlay über der Zelle (nicht
in ihr) und wächst mit dem Inhalt — in der Breite bis zu einer angenäherten
„sichtbaren Anzeigebreite", danach umbrechend; in der Höhe mit `Alt`+`Enter`