diff --git a/packages/store/src/history.test.ts b/packages/store/src/history.test.ts index 6b2eca9d..d9d62df4 100644 --- a/packages/store/src/history.test.ts +++ b/packages/store/src/history.test.ts @@ -90,4 +90,51 @@ describe('TemporalHistory Middleware', () => { expect(history.past).toEqual(['State 1', 'State 2']); expect(history.future).toEqual([]); // 'State 3' is erased forever }); + + // ── Object state deduplication ────────────────────────────────────── + + it('does not push duplicate entries for structurally equal object states', () => { + const objectStore = createHistoryStore({ count: 0 }); + + objectStore.set({ count: 0 }); // different reference, identical content + objectStore.set({ count: 0 }); // again + + const history = objectStore.getHistory(); + expect(history.past.length).toBe(0); // was 2 before the fix + }); + + it('does push an entry when object content actually changes', () => { + const objectStore = createHistoryStore({ count: 0 }); + + objectStore.set({ count: 1 }); // genuinely different content + + const history = objectStore.getHistory(); + expect(history.past.length).toBe(1); + expect(objectStore.present).toEqual({ count: 1 }); + }); + + it('does not push duplicate entries for structurally equal array states', () => { + const arrayStore = createHistoryStore([1, 2, 3]); + + arrayStore.set([1, 2, 3]); // new array reference, same content + + const history = arrayStore.getHistory(); + expect(history.past.length).toBe(0); + }); + + it('accepts a custom equals function for non-serialisable types', () => { + const mapStore = createHistoryStore( + new Map([['x', 1]]), + { + equals: (a, b) => + a.size === b.size && + [...a.entries()].every(([k, v]) => b.get(k) === v), + } + ); + + mapStore.set(new Map([['x', 1]])); // same content, different reference + + const history = mapStore.getHistory(); + expect(history.past.length).toBe(0); + }); }); \ No newline at end of file diff --git a/packages/store/src/history.ts b/packages/store/src/history.ts index d97d0696..1b4cc6b3 100644 --- a/packages/store/src/history.ts +++ b/packages/store/src/history.ts @@ -43,13 +43,38 @@ export interface TemporalStoreActions { * immutable from the caller's perspective: `getHistory()` returns copies * of the arrays so external consumers cannot mutate internal state. */ -export function createHistoryStore(initialPresent: T): TemporalStoreActions { + +export interface HistoryStoreOptions { + /** + * Custom equality function used to decide whether a new state is + * identical to the current present. Defaults to JSON.stringify + * structural equality. Pass a custom comparator for types that are + * not JSON-serialisable (e.g. containing Date, Map, Set). + * + * @example + * // Using a shallow comparator for flat objects: + * createHistoryStore(initial, { + * equals: (a, b) => Object.keys(a).every(k => (a as any)[k] === (b as any)[k]) + * }) + */ + equals?: (a: T, b: T) => boolean; +} + +export function createHistoryStore(initialPresent: T, options: HistoryStoreOptions={}): TemporalStoreActions { let timeline: TemporalHistory = { past: [], present: initialPresent, future: [], } + const equals = options.equals ?? ((a: T, b: T): boolean => { + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return Object.is(a, b); + } + }); + return { // Readonly accessor for the current state. get present(): T { @@ -58,7 +83,7 @@ export function createHistoryStore(initialPresent: T): TemporalStoreActions