-
Notifications
You must be signed in to change notification settings - Fork 228
Expand file tree
/
Copy pathsnapshot-history.ts
More file actions
118 lines (109 loc) · 3.96 KB
/
Copy pathsnapshot-history.ts
File metadata and controls
118 lines (109 loc) · 3.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import type { Store } from './store.js';
export interface StoreSnapshot<T> {
id: number;
label?: string;
state: T;
}
export interface SnapshotHistoryOptions {
limit?: number;
}
export interface StoreSnapshotHistory<T extends object> {
capture(label?: string): StoreSnapshot<T>;
restore(id: number): StoreSnapshot<T>;
undo(): StoreSnapshot<T> | null;
redo(): StoreSnapshot<T> | null;
list(): StoreSnapshot<T>[];
clear(): void;
}
// Deep-clone while stripping function values at every level — action
// methods (set()-bound closures) live directly on store state and are not
// structured-cloneable. This mirrors store.ts's own safeDeepClone, which
// exists for the same reason.
function cloneState<T>(state: T): T {
const strip = (value: unknown): unknown => {
if (value === null || typeof value !== 'object') return value;
if (value instanceof Date || value instanceof RegExp) return value;
if (Array.isArray(value)) return value.map(strip);
if (value instanceof Map) {
const m = new Map();
for (const [k, v] of value) m.set(k, strip(v));
return m;
}
if (value instanceof Set) {
const s = new Set();
for (const v of value) s.add(strip(v));
return s;
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (typeof v === 'function') continue;
out[k] = strip(v);
}
return out;
};
const stripped = strip(state);
return typeof structuredClone === 'function'
? structuredClone(stripped as T)
: (JSON.parse(JSON.stringify(stripped)) as T);
}
export function createSnapshotHistory<T extends object>(
store: Store<T>,
options: SnapshotHistoryOptions = {},
): StoreSnapshotHistory<T> {
const limit = options.limit ?? 50;
const snapshots: StoreSnapshot<T>[] = [];
let cursor = -1;
let nextId = 1;
const apply = (snapshot: StoreSnapshot<T>): StoreSnapshot<T> => {
const before = cloneState(store.getState());
try {
store.setState(cloneState(snapshot.state) as Partial<T>);
} catch (error) {
store.setState(before as Partial<T>);
throw error;
}
return { ...snapshot, state: cloneState(snapshot.state) };
};
return {
capture(label) {
snapshots.splice(cursor + 1);
const snapshot = { id: nextId++, label, state: cloneState(store.getState()) };
snapshots.push(snapshot);
while (snapshots.length > limit) {
snapshots.shift();
}
cursor = snapshots.length - 1;
return { ...snapshot, state: cloneState(snapshot.state) };
},
restore(id) {
const index = snapshots.findIndex(snapshot => snapshot.id === id);
if (index === -1) throw new Error(`Unknown store snapshot: ${id}`);
const result = apply(snapshots[index]);
// Only advance the cursor once the state change actually succeeded —
// otherwise a thrown setState leaves cursor out of sync with live state.
cursor = index;
return result;
},
undo() {
if (cursor <= 0) return null;
const targetIndex = cursor - 1;
const result = apply(snapshots[targetIndex]);
cursor = targetIndex;
return result;
},
redo() {
if (cursor >= snapshots.length - 1) return null;
const targetIndex = cursor + 1;
const result = apply(snapshots[targetIndex]);
cursor = targetIndex;
return result;
},
list() {
return snapshots.map(snapshot => ({ ...snapshot, state: cloneState(snapshot.state) }));
},
clear() {
snapshots.splice(0);
cursor = -1;
},
};
}