Skip to content

Commit 86d8b20

Browse files
Make undo/redo per-gesture instead of time-throttled (#634)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent bd53520 commit 86d8b20

4 files changed

Lines changed: 31 additions & 16 deletions

File tree

app/src/app/constants/document/temporal.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,5 @@
66
*/
77
export const OFFSET_FACTOR: number = 15;
88

9-
/** Minimum milliseconds between undo/redo history snapshots. */
10-
export const MIN_DIFF_MS = 3000;
11-
129
/** Maximum number of undo/redo history states to keep per store. */
1310
export const TEMPORAL_HISTORY_LIMIT = 20;

app/src/app/store/assignmentsStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,11 @@ export const useAssignmentsStore = createWithFullMiddlewares<AssignmentsStore>(
553553
childToParent,
554554
};
555555
} else {
556+
// Healing is an automatic consequence of the triggering gesture (exiting block
557+
// view), not a user edit — suppress its undo snapshot so it coalesces into that
558+
// gesture's history entry instead of adding a transient pre-heal step.
559+
const {isTracking, pause} = useAssignmentsStore.temporal.getState();
560+
isTracking && pause();
556561
set({
557562
zoneAssignments: new Map(zoneAssignments),
558563
accumulatedAssignments: new Map<string, NullableZone>(),
@@ -566,6 +571,7 @@ export const useAssignmentsStore = createWithFullMiddlewares<AssignmentsStore>(
566571
pendingShatterUndoState: null,
567572
zonesLastUpdated: new Map(get().zonesLastUpdated),
568573
});
574+
isTracking && useAssignmentsStore.temporal.getState().resume();
569575
}
570576
},
571577
ingestAccumulatedAssignments: () => {

app/src/app/store/coiAssignmentsStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,11 @@ export const useCoiAssignmentsStore = createWithFullMiddlewares<CoiAssignmentsSt
10951095
if (!healed) return;
10961096

10971097
const currentTime = new Date().toISOString();
1098+
// Healing is an automatic consequence of the gesture that triggered it (paint or
1099+
// exiting block view), not a user edit — suppress its undo snapshot so it coalesces
1100+
// into that gesture's history entry instead of adding a transient pre-heal step.
1101+
const {isTracking, pause, resume} = useCoiAssignmentsStore.temporal.getState();
1102+
isTracking && pause();
10981103
set({
10991104
communityAssignments,
11001105
accumulatedAssignments: new Map<string, CoiAccumulatedMutation>(),
@@ -1104,6 +1109,7 @@ export const useCoiAssignmentsStore = createWithFullMiddlewares<CoiAssignmentsSt
11041109
childToParent,
11051110
clientLastUpdated: currentTime,
11061111
});
1112+
isTracking && resume();
11071113

11081114
if (mapDocument) {
11091115
idb.updateIdbCoiAssignments(mapDocument, communityAssignments, currentTime, true);

app/src/app/store/middlewareConfig.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {devtools, DevtoolsOptions, PersistOptions} from 'zustand/middleware';
22
import {MapStore} from './mapStore';
3-
import {MIN_DIFF_MS} from '@constants/document/temporal';
43
import {ZundoOptions} from 'zundo';
54
import {AssignmentsStore} from './assignmentsStore';
65
import {CoiAssignmentsStore} from './coiAssignmentsStore';
@@ -35,9 +34,14 @@ export const devToolsConfig: DevtoolsOptions = {
3534
},
3635
};
3736

38-
// Shared diff function for all temporal stores — only fires when clientLastUpdated changes
39-
// and enough time has passed since the last snapshot. Generic over the store type so the
40-
// same function can drive both district and COI zundo configurations.
37+
// Shared diff function for all temporal stores — fires once per real edit (every set that
38+
// bumps clientLastUpdated AND replaces a tracked collection). Generic over the store type so
39+
// the same function can drive both district and COI zundo configurations.
40+
//
41+
// Every mutation path replaces tracked Maps/Sets wholesale, so reference equality across all
42+
// partialized keys is a reliable O(keys) "did anything actually change" check. Timestamp-only
43+
// bumps (comment/metadata edits, save syncs) keep identical refs and are skipped — otherwise
44+
// they'd create dead undo steps that appear to do nothing.
4145
interface TemporalDiffSnapshot {
4246
clientLastUpdated?: string;
4347
pendingShatterUndoState?: AssignmentsStore['pendingShatterUndoState'];
@@ -51,16 +55,18 @@ export const temporalDiff = <T extends TemporalDiffSnapshot>(
5155
if (!past.clientLastUpdated || !curr.clientLastUpdated) return null;
5256
// If the client timestamp is the same, don't store
5357
if (past.clientLastUpdated === curr.clientLastUpdated) return null;
54-
// If not yet ingested, don't store
55-
if (past.clientLastUpdated === '' || curr.clientLastUpdated === '') return null;
56-
// If the difference is less than the minimum diff time, don't store
57-
if (
58-
new Date(curr.clientLastUpdated).getTime() - new Date(past.clientLastUpdated).getTime() <
59-
MIN_DIFF_MS
60-
)
61-
return null;
58+
// Timestamp-only bump: no tracked collection was replaced, so nothing to undo
59+
const contentChanged = (Object.keys(past) as Array<keyof T>).some(
60+
key => key !== 'clientLastUpdated' && past[key] !== curr[key]
61+
);
62+
if (!contentChanged) return null;
6263
if (past.pendingShatterUndoState && !curr.pendingShatterUndoState) {
63-
return cloneTemporalSnapshot(past.pendingShatterUndoState) as unknown as Partial<T>;
64+
// pendingShatterUndoState: null so restoring this entry can't leave a stale
65+
// pending snapshot in the store (entries are applied as partial merges).
66+
return {
67+
...cloneTemporalSnapshot(past.pendingShatterUndoState),
68+
pendingShatterUndoState: null,
69+
} as unknown as Partial<T>;
6470
}
6571
return past;
6672
};

0 commit comments

Comments
 (0)