diff --git a/src/draft.ts b/src/draft.ts index 2c99c6b2..a22a7583 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -162,7 +162,7 @@ const proxyHandler: ProxyHandler = { ) return true; ensureShallowCopy(target); - markChanged(target); + markChanged(target, key, { kind: 'set', prev: current, next: value }); if (has(target.original, key) && isEqual(value, target.original[key])) { // !case: handle the case of assigning the original non-draftable value to a draft target.assignedMap!.delete(key); @@ -203,10 +203,11 @@ const proxyHandler: ProxyHandler = { if (target.type === DraftType.Array) { return proxyHandler.set!.call(this, target, key, undefined, target.proxy); } - if (peek(target.original, key) !== undefined || key in target.original) { + const prev = peek(target.original, key); + if (prev !== undefined || key in target.original) { // !case: delete an existing key ensureShallowCopy(target); - markChanged(target); + markChanged(target, key, { kind: 'delete', prev }); target.assignedMap!.set(key, false); } else { target.assignedMap = target.assignedMap ?? new Map(); diff --git a/src/interface.ts b/src/interface.ts index d38d969a..1b2d50f1 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -109,6 +109,19 @@ export interface ApplyMutableOptions { mutable?: boolean; } +export interface ChangeInput { + kind: 'set' | 'delete' | 'map.set' | 'map.delete' | 'map.clear' | 'set.add' | 'set.delete' | 'set.clear'; + prev?: any; + next?: any; + value?: any; + existed?: boolean; +} + +export interface ChangeEvent extends ChangeInput { + key?: any; + path: (string | number)[]; +} + export interface Options { /** * In strict mode, Forbid accessing non-draftable values and forbid returning a non-draft value. @@ -127,6 +140,10 @@ export interface Options { * And it can also return a shallow copy function(AutoFreeze and Patches should both be disabled). */ mark?: Mark; + /** + * onChange callback. When set, caller can be aware of each change as it happens. + */ + onChange?: (event: ChangeEvent) => void; } export interface ExternalOptions { @@ -147,6 +164,10 @@ export interface ExternalOptions { * And it can also return a shallow copy function(AutoFreeze and Patches should both be disabled). */ mark?: Mark[] | Mark; + /** + * onChange callback. When set, caller can be aware of each change as it happens. + */ + onChange?: (event: ChangeEvent) => void; } // Exclude `symbol` diff --git a/src/makeCreator.ts b/src/makeCreator.ts index e4e93881..9ad98055 100644 --- a/src/makeCreator.ts +++ b/src/makeCreator.ts @@ -148,6 +148,7 @@ export const makeCreator: MakeCreator = (arg) => { mark, strict, enablePatches, + onChange: options.onChange, }; if ( !isDraftable(state, _options) && diff --git a/src/map.ts b/src/map.ts index 52dbc2df..f9aca49f 100644 --- a/src/map.ts +++ b/src/map.ts @@ -23,9 +23,10 @@ export const mapHandler = { set(key: any, value: any) { const target = getProxyDraft(this)!; const source = latest(target); - if (!source.has(key) || !isEqual(source.get(key), value)) { + const prev = source.get(key); + if (!source.has(key) || !isEqual(prev, value)) { ensureShallowCopy(target); - markChanged(target); + markChanged(target, key, { kind:'map.set', prev, next: value }); target.assignedMap!.set(key, true); target.copy.set(key, value); markFinalization(target, key, value, generatePatches); @@ -37,8 +38,9 @@ export const mapHandler = { return false; } const target = getProxyDraft(this)!; + const prev = latest(target).get(key); ensureShallowCopy(target); - markChanged(target); + markChanged(target, key, { kind:'map.delete', prev }); if (target.original.has(key)) { target.assignedMap!.set(key, false); } else { @@ -51,7 +53,7 @@ export const mapHandler = { const target = getProxyDraft(this)!; if (!this.size) return; ensureShallowCopy(target); - markChanged(target); + markChanged(target, undefined, { kind:'map.clear' }); target.assignedMap = new Map(); for (const [key] of target.original) { target.assignedMap.set(key, false); diff --git a/src/set.ts b/src/set.ts index 824aa735..92922fe9 100644 --- a/src/set.ts +++ b/src/set.ts @@ -75,7 +75,7 @@ export const setHandler = { const target = getProxyDraft(this)!; if (!this.has(value)) { ensureShallowCopy(target); - markChanged(target); + markChanged(target, undefined, { kind:'set.add', value }); target.assignedMap!.set(value, true); target.setMap!.set(value, value); markFinalization(target, value, value, generatePatches); @@ -83,33 +83,39 @@ export const setHandler = { return this; }, delete(value: any): boolean { - if (!this.has(value)) { + const existed = this.has(value); + if (!existed) { return false; } const target = getProxyDraft(this)!; ensureShallowCopy(target); - markChanged(target); + markChanged(target, undefined, { kind:'set.delete', value, existed }); const valueProxyDraft = getProxyDraft(value)!; + let ok: boolean; if (valueProxyDraft && target.setMap!.has(valueProxyDraft.original)) { // delete drafted target.assignedMap!.set(valueProxyDraft.original, false); - return target.setMap!.delete(valueProxyDraft.original); + ok = target.setMap!.delete(valueProxyDraft.original); } - if (!valueProxyDraft && target.setMap!.has(value)) { - // non-draftable values - target.assignedMap!.set(value, false); - } else { - // reassigned - target.assignedMap!.delete(value); + else { + if (!valueProxyDraft && target.setMap!.has(value)) { + // non-draftable values + target.assignedMap!.set(value, false); + } + else { + // reassigned + target.assignedMap!.delete(value); + } + // delete reassigned or non-draftable values + ok = target.setMap!.delete(value); } - // delete reassigned or non-draftable values - return target.setMap!.delete(value); + return ok; }, clear() { if (!this.size) return; const target = getProxyDraft(this)!; ensureShallowCopy(target); - markChanged(target); + markChanged(target, undefined, { kind:'set.clear' }); for (const value of target.original) { target.assignedMap!.set(value, false); } diff --git a/src/utils/mark.ts b/src/utils/mark.ts index 97ef44fd..4d985fe5 100644 --- a/src/utils/mark.ts +++ b/src/utils/mark.ts @@ -1,6 +1,6 @@ -import { ProxyDraft } from '../interface'; +import { ProxyDraft, ChangeInput } from '../interface'; -export function markChanged(proxyDraft: ProxyDraft) { +export function markChanged(proxyDraft: ProxyDraft, key?: string | number | symbol, operation?: ChangeInput) { proxyDraft.assignedMap = proxyDraft.assignedMap ?? new Map(); if (!proxyDraft.operated) { proxyDraft.operated = true; @@ -8,4 +8,33 @@ export function markChanged(proxyDraft: ProxyDraft) { markChanged(proxyDraft.parent); } } + + // Emit operation hook if provided + if (operation && proxyDraft.options.onChange) { + try { + // Build path from root to this node + const path: (string | number)[] = []; + let current: ProxyDraft | null = proxyDraft; + + while (current && current.parent) { + if (current.key !== undefined) { + path.unshift(current.key as string | number); + } + current = current.parent; + } + + // Add the current key if provided + if (key !== undefined) { + path.push(key as string | number); + } + + proxyDraft.options.onChange({ + ...operation, + path, + key, + }); + } catch (e) { + // Be conservative: never throw from hooks + } + } } diff --git a/test/hooks.test.ts b/test/hooks.test.ts new file mode 100644 index 00000000..72ac6dd3 --- /dev/null +++ b/test/hooks.test.ts @@ -0,0 +1,273 @@ +/* eslint-disable no-param-reassign */ +import { create } from '../src'; + +describe('onChange', () => { + test('object operations', () => { + const operations: any[] = []; + const baseState: { + name: string; + age?: number; + } = { + name: 'Alice', + age: 30, + }; + + const state = create( + baseState, + (draft) => { + draft.name = 'Bob'; + draft.age = 31; + delete draft.age; + }, + { + onChange: (event) => { + operations.push(event); + }, + } + ); + + expect(state).toEqual({ name: 'Bob' }); + expect(operations).toHaveLength(3); + + // Check set operation + expect(operations[0]).toEqual({ + kind: 'set', + key: 'name', + prev: 'Alice', + next: 'Bob', + path: ['name'], + }); + + // Check set operation for age + expect(operations[1]).toEqual({ + kind: 'set', + key: 'age', + prev: 30, + next: 31, + path: ['age'], + }); + + // Check delete operation + expect(operations[2]).toEqual({ + kind: 'delete', + key: 'age', + prev: 30, // This captures the original value, not the modified value + path: ['age'], + }); + }); + + test('map operations', () => { + const operations: any[] = []; + const baseState = { + map: new Map([['key1', 'value1'], ['key2', 'value2']]), + }; + + const state = create( + baseState, + (draft) => { + draft.map.set('key3', 'value3'); + draft.map.set('key1', 'newValue1'); + draft.map.delete('key2'); + draft.map.clear(); + }, + { + onChange: (event) => { + operations.push(event); + }, + } + ); + + expect(state.map.size).toBe(0); + expect(operations).toHaveLength(4); + + // Check map.set operation (new key) + expect(operations[0]).toEqual({ + kind: 'map.set', + key: 'key3', + prev: undefined, + next: 'value3', + path: ['map', 'key3'], + }); + + // Check map.set operation (existing key) + expect(operations[1]).toEqual({ + kind: 'map.set', + key: 'key1', + prev: 'value1', + next: 'newValue1', + path: ['map', 'key1'], + }); + + // Check map.delete operation + expect(operations[2]).toEqual({ + kind: 'map.delete', + key: 'key2', + prev: 'value2', + path: ['map', 'key2'], + }); + + // Check map.clear operation + expect(operations[3]).toEqual({ + kind: 'map.clear', + path: ['map'], + }); + }); + + test('set operations', () => { + const operations: any[] = []; + const baseState = { + set: new Set(['value1', 'value2']), + }; + + const state = create( + baseState, + (draft) => { + draft.set.add('value3'); + draft.set.add('value1'); // Should not trigger (already exists) + draft.set.delete('value2'); + draft.set.clear(); + }, + { + onChange: (event) => { + operations.push(event); + }, + } + ); + + expect(state.set.size).toBe(0); + expect(operations).toHaveLength(3); // add, delete, clear (no duplicate add) + + // Check set.add operation + expect(operations[0]).toEqual({ + kind: 'set.add', + value: 'value3', + path: ['set'], + }); + + // Check set.delete operation + expect(operations[1]).toEqual({ + kind: 'set.delete', + value: 'value2', + existed: true, + path: ['set'], + }); + + // Check set.clear operation + expect(operations[2]).toEqual({ + kind: 'set.clear', + path: ['set'], + }); + }); + + test('nested object operations', () => { + const operations: any[] = []; + const baseState: { + user: { + profile: { + name: string; + age?: number; + }; + }; + } = { + user: { + profile: { + name: 'Alice', + age: 30, + }, + }, + }; + + const state = create( + baseState, + (draft) => { + draft.user.profile.name = 'Bob'; + delete draft.user.profile.age; + }, + { + onChange: (event) => { + operations.push(event); + }, + } + ); + + expect(state.user.profile).toEqual({ name: 'Bob' }); + expect(operations).toHaveLength(2); + + // Check nested set operation + expect(operations[0]).toEqual({ + kind: 'set', + key: 'name', + prev: 'Alice', + next: 'Bob', + path: ['user', 'profile', 'name'], + }); + + // Check nested delete operation + expect(operations[1]).toEqual({ + kind: 'delete', + key: 'age', + prev: 30, + path: ['user', 'profile', 'age'], + }); + }); + + test('with error handling', () => { + const baseState = { name: 'Alice' }; + + // Should not throw even if hook throws an error + const state = create( + baseState, + (draft) => { + draft.name = 'Bob'; + }, + { + onChange: () => { + throw new Error('Hook error'); + }, + } + ); + + expect(state).toEqual({ name: 'Bob' }); + }); + + test('array operations', () => { + const operations: any[] = []; + const baseState = { + items: ['item1', 'item2'], + }; + + const state = create( + baseState, + (draft) => { + draft.items[0] = 'newItem1'; + draft.items.push('item3'); + }, + { + onChange: (event) => { + operations.push(event); + }, + } + ); + + expect(state.items).toEqual(['newItem1', 'item2', 'item3']); + expect(operations).toHaveLength(2); // array[0] set, array[2] set (length changes aren't captured by markChanged) + + // Check array set operation + expect(operations[0]).toEqual({ + kind: 'set', + key: '0', + prev: 'item1', + next: 'newItem1', + path: ['items', '0'], + }); + + // Check array push operation (sets new item) + expect(operations[1]).toEqual({ + kind: 'set', + key: '2', + prev: undefined, + next: 'item3', + path: ['items', '2'], + }); + }); +}); \ No newline at end of file