patch-recorder 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/optimizer.ts CHANGED
@@ -1,53 +1,283 @@
1
- import type {Patches} from './types.js';
1
+ import type {Patch, Patches, PatchPath} from './types.js';
2
+ import {pathToKey} from './utils.js';
3
+
4
+ // ==================== Nested Map data structure ====================
2
5
 
3
6
  /**
4
- * Compress patches by merging redundant operations
5
- * This handles both consecutive and interleaved operations on the same path
7
+ * Node in the path tree for efficient path lookup
8
+ */
9
+ interface PathNode {
10
+ patch?: Patch;
11
+ children?: Map<string | number | symbol | object, PathNode>;
12
+ }
13
+
14
+ /**
15
+ * Navigate to a node in the path tree, creating nodes along the way
16
+ */
17
+ function getOrCreateNode(root: PathNode, path: PatchPath): PathNode {
18
+ let current = root;
19
+ for (const key of path) {
20
+ if (!current.children) {
21
+ current.children = new Map();
22
+ }
23
+ let child = current.children.get(key);
24
+ if (!child) {
25
+ child = {};
26
+ current.children.set(key, child);
27
+ }
28
+ current = child;
29
+ }
30
+ return current;
31
+ }
32
+
33
+ /**
34
+ * Collect all patches from the path tree
35
+ */
36
+ function collectPatches(node: PathNode, patches: Patches = []): Patches {
37
+ if (node.patch) {
38
+ patches.push(node.patch);
39
+ }
40
+ if (node.children) {
41
+ for (const child of node.children.values()) {
42
+ collectPatches(child, patches);
43
+ }
44
+ }
45
+ return patches;
46
+ }
47
+
48
+ // ==================== V2: Nested Map optimizer (faster) ====================
49
+
50
+ /**
51
+ * Compress patches by merging redundant operations using nested Maps
52
+ * This is faster than the string-key version because:
53
+ * - No string allocation for path keys
54
+ * - Preserves symbol and object identity
55
+ * - 2.5-5x faster in benchmarks
56
+ */
57
+ export function compressPatchesWithNestedMaps(patches: Patches): Patches {
58
+ if (patches.length === 0) {
59
+ return patches;
60
+ }
61
+
62
+ // Use a nested Map tree to track the latest operation for each path
63
+ const root: PathNode = {};
64
+
65
+ for (const patch of patches) {
66
+ const node = getOrCreateNode(root, patch.path);
67
+ const existing = node.patch;
68
+
69
+ if (!existing) {
70
+ // First operation on this path
71
+ node.patch = patch;
72
+ } else {
73
+ // Merge with existing operation based on operation types
74
+ const merged = mergePatches(existing, patch);
75
+ // Check for undefined specifically (null means canceled, which is a valid result)
76
+ if (merged !== undefined) {
77
+ // Update with merged result (or null if they cancel out)
78
+ if (merged !== null) {
79
+ node.patch = merged;
80
+ } else {
81
+ // Operations canceled each other out
82
+ delete node.patch;
83
+ }
84
+ } else {
85
+ // Can't merge, keep the new operation
86
+ node.patch = patch;
87
+ }
88
+ }
89
+ }
90
+
91
+ // Collect all patches from tree
92
+ let finalPatches = collectPatches(root);
93
+
94
+ // Handle array push + pop cancellation
95
+ // Only cancel when push is at the last index and pop reduces length
96
+ finalPatches = cancelArrayPushPop(finalPatches);
97
+
98
+ // Cancel patches that are beyond array bounds after final length update
99
+ finalPatches = cancelOutOfBoundsPatches(finalPatches);
100
+
101
+ return finalPatches;
102
+ }
103
+
104
+ // ==================== V1: String key optimizer (original) ====================
105
+
106
+ /**
107
+ * Compress patches by merging redundant operations using string keys
108
+ * This is the original implementation that uses pathToKey for path lookup.
6
109
  */
7
- export function compressPatches(patches: Patches<true>): Patches<true> {
110
+ export function compressPatchesWithStringKeys(patches: Patches): Patches {
8
111
  if (patches.length === 0) {
9
112
  return patches;
10
113
  }
11
114
 
12
115
  // Use a Map to track the latest operation for each path
13
- // Key: JSON stringified path, Value: the latest patch for that path
14
- const pathMap = new Map<string, any>();
116
+ // Key: optimized path string (using pathToKey), Value: the latest patch for that path
117
+ const pathMap = new Map<string, Patch>();
15
118
 
16
119
  for (const patch of patches) {
17
- const pathKey = JSON.stringify(patch.path);
120
+ const pathKey = pathToKey(patch.path);
18
121
  const existing = pathMap.get(pathKey);
19
122
 
20
123
  if (!existing) {
21
124
  // First operation on this path
22
125
  pathMap.set(pathKey, patch);
126
+ } else {
127
+ // Merge with existing operation based on operation types
128
+ const merged = mergePatches(existing, patch);
129
+ // Check for undefined specifically (null means canceled, which is a valid result)
130
+ if (merged !== undefined) {
131
+ // Update with merged result (or null if they cancel out)
132
+ if (merged !== null) {
133
+ pathMap.set(pathKey, merged);
134
+ } else {
135
+ // Operations canceled each other out
136
+ pathMap.delete(pathKey);
137
+ }
138
+ } else {
139
+ // Can't merge, keep the new operation
140
+ pathMap.set(pathKey, patch);
141
+ }
142
+ }
143
+ }
144
+
145
+ // Convert Map to array for final processing
146
+ let finalPatches = Array.from(pathMap.values());
147
+
148
+ // Handle array push + pop cancellation
149
+ // Only cancel when push is at the last index and pop reduces length
150
+ finalPatches = cancelArrayPushPop(finalPatches);
151
+
152
+ // Cancel patches that are beyond array bounds after final length update
153
+ finalPatches = cancelOutOfBoundsPatches(finalPatches);
154
+
155
+ return finalPatches;
156
+ }
157
+
158
+ // ==================== Default export ====================
159
+
160
+ /**
161
+ * Compress patches by merging redundant operations
162
+ * This handles both consecutive and interleaved operations on the same path
163
+ *
164
+ * Uses the nested Map implementation for better performance (2.5-5x faster)
165
+ */
166
+ export const compressPatches = compressPatchesWithNestedMaps;
167
+
168
+ // ==================== Post-processing functions (shared) ====================
169
+
170
+ /**
171
+ * Cancel array push + pop operations
172
+ * Only cancels when push is at the last index and pop reduces length
173
+ *
174
+ * Note: Uses pathToKey for grouping since this works on already-compressed patches
175
+ * (smaller set) and the performance benefit of nested Maps is less significant here.
176
+ */
177
+ function cancelArrayPushPop(patches: Patches): Patches {
178
+ // Group patches by parent array path
179
+ const arrayGroups = new Map<string, Patches>();
180
+
181
+ for (const patch of patches) {
182
+ if (!Array.isArray(patch.path) || patch.path.length < 2) {
23
183
  continue;
24
184
  }
25
185
 
26
- // Merge with existing operation based on operation types
27
- const merged = mergePatches(existing, patch);
28
- if (merged) {
29
- // Update with merged result (or null if they cancel out)
30
- if (merged !== null) {
31
- pathMap.set(pathKey, merged);
32
- } else {
33
- // Operations canceled each other out
34
- pathMap.delete(pathKey);
186
+ const parentPath = patch.path.slice(0, -1);
187
+ const parentKey = pathToKey(parentPath);
188
+
189
+ if (!arrayGroups.has(parentKey)) {
190
+ arrayGroups.set(parentKey, []);
191
+ }
192
+ arrayGroups.get(parentKey)!.push(patch);
193
+ }
194
+
195
+ // Use WeakSet to track cancelable patches by reference (no string allocation)
196
+ const cancelablePatches = new WeakSet<Patch>();
197
+
198
+ for (const [, groupPatches] of arrayGroups.entries()) {
199
+ // Find push patches (add at highest indices)
200
+ const pushPatches = groupPatches
201
+ .filter((p) => p.op === 'add' && typeof p.path[p.path.length - 1] === 'number')
202
+ .sort(
203
+ (a, b) => (b.path[b.path.length - 1] as number) - (a.path[a.path.length - 1] as number),
204
+ );
205
+
206
+ // Find pop patches (length reduction)
207
+ const popPatches = groupPatches.filter(
208
+ (p) => p.op === 'replace' && p.path[p.path.length - 1] === 'length',
209
+ );
210
+
211
+ // Cancel pushes and pops that match (push at highest index, pop reduces length)
212
+ const cancelCount = Math.min(pushPatches.length, popPatches.length);
213
+ for (let i = 0; i < cancelCount; i++) {
214
+ const pushPatch = pushPatches[i];
215
+ const popPatch = popPatches[i];
216
+
217
+ // Check if the push index matches the pop target
218
+ const pushIndex = pushPatch.path[pushPatch.path.length - 1] as number;
219
+ const popLength = popPatch.value as number;
220
+
221
+ // If push added at index pushIndex and pop reduced to popLength, they cancel
222
+ // This is a heuristic: push adds at end, pop removes from end
223
+ if (pushIndex >= popLength) {
224
+ cancelablePatches.add(pushPatch);
225
+ cancelablePatches.add(popPatch);
35
226
  }
36
- } else {
37
- // Can't merge, keep the new operation
38
- pathMap.set(pathKey, patch);
39
227
  }
40
228
  }
41
229
 
42
- // Convert Map back to array
43
- return Array.from(pathMap.values()) as Patches<true>;
230
+ return patches.filter((patch) => !cancelablePatches.has(patch));
231
+ }
232
+
233
+ /**
234
+ * Cancel patches that are beyond array bounds after final length update
235
+ */
236
+ function cancelOutOfBoundsPatches(patches: Patches): Patches {
237
+ // Find the final length for each array
238
+ const arrayLengths = new Map<string, number>();
239
+
240
+ for (const patch of patches) {
241
+ if (
242
+ Array.isArray(patch.path) &&
243
+ patch.path.length >= 2 &&
244
+ patch.path[patch.path.length - 1] === 'length'
245
+ ) {
246
+ const parentPath = pathToKey(patch.path.slice(0, -1));
247
+ arrayLengths.set(parentPath, patch.value as number);
248
+ }
249
+ }
250
+
251
+ // Use WeakSet to track canceled patches by reference (no string allocation)
252
+ const canceledPatches = new WeakSet<Patch>();
253
+
254
+ // Cancel patches at indices >= final length
255
+ for (const patch of patches) {
256
+ if (!Array.isArray(patch.path) || patch.path.length < 2) {
257
+ continue;
258
+ }
259
+
260
+ const lastPath = patch.path[patch.path.length - 1];
261
+ const parentPath = pathToKey(patch.path.slice(0, -1));
262
+
263
+ if (typeof lastPath === 'number' && arrayLengths.has(parentPath)) {
264
+ const length = arrayLengths.get(parentPath)!;
265
+ if (lastPath >= length) {
266
+ canceledPatches.add(patch);
267
+ }
268
+ }
269
+ }
270
+
271
+ return patches.filter((patch) => !canceledPatches.has(patch));
44
272
  }
45
273
 
274
+ // ==================== Patch merging logic (shared) ====================
275
+
46
276
  /**
47
277
  * Merge two patches on the same path
48
278
  * Returns the merged patch, or null if they cancel out, or undefined if they can't be merged
49
279
  */
50
- function mergePatches(patch1: any, patch2: any): any | null | undefined {
280
+ function mergePatches(patch1: Patch, patch2: Patch): Patch | null | undefined {
51
281
  const op1 = patch1.op;
52
282
  const op2 = patch2.op;
53
283
 
@@ -55,19 +285,19 @@ function mergePatches(patch1: any, patch2: any): any | null | undefined {
55
285
  if (op1 === op2) {
56
286
  // For replace operations, keep the latest value
57
287
  if (op1 === 'replace') {
58
- // Skip if same value (no-op)
59
- if (valuesEqual(patch1.value, patch2.value)) {
288
+ // Skip if same reference (no-op)
289
+ if (patch1.value === patch2.value) {
60
290
  return patch1;
61
291
  }
62
292
  return patch2;
63
293
  }
64
- // For add operations, if adding the same value, it's a no-op
65
- if (op1 === 'add' && valuesEqual(patch1.value, patch2.value)) {
294
+ // For add operations, if adding same reference, it's a no-op
295
+ if (op1 === 'add' && patch1.value === patch2.value) {
66
296
  return patch1;
67
297
  }
68
- // For remove operations, keep the latest
298
+ // For remove operations, don't merge (sequential removes should never be merged)
69
299
  if (op1 === 'remove') {
70
- return patch2;
300
+ return undefined;
71
301
  }
72
302
  }
73
303
 
@@ -93,44 +323,14 @@ function mergePatches(patch1: any, patch2: any): any | null | undefined {
93
323
  }
94
324
 
95
325
  if (op1 === 'remove' && op2 === 'add') {
96
- // Remove then add - keep the add
97
- return patch2;
326
+ // Remove then add - this is a replace operation
327
+ return {
328
+ op: 'replace',
329
+ path: patch1.path,
330
+ value: patch2.value,
331
+ };
98
332
  }
99
333
 
100
334
  // Can't merge these operations
101
335
  return undefined;
102
- }
103
-
104
- /**
105
- * Check if two values are equal (deep comparison)
106
- */
107
- function valuesEqual(val1: any, val2: any): boolean {
108
- if (val1 === val2) return true;
109
-
110
- // Handle null/undefined
111
- if (val1 == null || val2 == null) return val1 === val2;
112
-
113
- // Handle arrays
114
- if (Array.isArray(val1) && Array.isArray(val2)) {
115
- if (val1.length !== val2.length) return false;
116
- for (let i = 0; i < val1.length; i++) {
117
- if (!valuesEqual(val1[i], val2[i])) return false;
118
- }
119
- return true;
120
- }
121
-
122
- // Handle objects
123
- if (typeof val1 === 'object' && typeof val2 === 'object') {
124
- const keys1 = Object.keys(val1);
125
- const keys2 = Object.keys(val2);
126
-
127
- if (keys1.length !== keys2.length) return false;
128
-
129
- for (const key of keys1) {
130
- if (!valuesEqual(val1[key], val2[key])) return false;
131
- }
132
- return true;
133
- }
134
-
135
- return false;
136
- }
336
+ }
package/src/patches.ts CHANGED
@@ -1,22 +1,31 @@
1
- import type {RecorderState} from './types.js';
1
+ import type {NonPrimitive, Patch, PatchPath, RecorderState} from './types.js';
2
2
  import {Operation} from './types.js';
3
- import {formatPath, cloneIfNeeded} from './utils.js';
3
+ import {cloneIfNeeded, findGetItemIdFn} from './utils.js';
4
4
 
5
5
  /**
6
6
  * Generate a replace patch for property changes
7
7
  */
8
8
  export function generateSetPatch(
9
- state: RecorderState<any>,
10
- path: (string | number)[],
11
- oldValue: any,
12
- newValue: any,
9
+ state: RecorderState<NonPrimitive>,
10
+ path: PatchPath,
11
+ oldValue: unknown,
12
+ newValue: unknown,
13
13
  ) {
14
- const patch = {
14
+ const patch: any = {
15
15
  op: Operation.Replace,
16
- path: formatPath(path, state.options),
16
+ path,
17
17
  value: cloneIfNeeded(newValue),
18
18
  };
19
19
 
20
+ // Add id if getItemId is configured for this path
21
+ const getItemIdFn = findGetItemIdFn(path, state.options.getItemId);
22
+ if (getItemIdFn && oldValue !== undefined) {
23
+ const id = getItemIdFn(oldValue);
24
+ if (id !== undefined && id !== null) {
25
+ patch.id = id;
26
+ }
27
+ }
28
+
20
29
  state.patches.push(patch);
21
30
  }
22
31
 
@@ -24,28 +33,36 @@ export function generateSetPatch(
24
33
  * Generate a remove patch for property deletions
25
34
  */
26
35
  export function generateDeletePatch(
27
- state: RecorderState<any>,
28
- path: (string | number)[],
29
- oldValue: any,
36
+ state: RecorderState<NonPrimitive>,
37
+ path: PatchPath,
38
+ oldValue: unknown,
30
39
  ) {
31
- const patch = {
40
+ const patch: Patch = {
32
41
  op: Operation.Remove,
33
- path: formatPath(path, state.options),
42
+ path: path,
34
43
  };
35
44
 
45
+ // Add id if getItemId is configured for this path
46
+ const getItemIdFn = findGetItemIdFn(path, state.options.getItemId);
47
+ if (getItemIdFn && oldValue !== undefined) {
48
+ const id = getItemIdFn(oldValue);
49
+ if (id !== undefined && id !== null) {
50
+ patch.id = id;
51
+ }
52
+ }
53
+
36
54
  state.patches.push(patch);
37
55
  }
38
56
 
39
57
  /**
40
58
  * Generate an add patch for new properties
41
59
  */
42
- export function generateAddPatch(state: RecorderState<any>, path: (string | number)[], value: any) {
43
- const patch = {
60
+ export function generateAddPatch(state: RecorderState<any>, path: PatchPath, value: any) {
61
+ const patch: Patch = {
44
62
  op: Operation.Add,
45
- path: formatPath(path, state.options),
63
+ path,
46
64
  value: cloneIfNeeded(value),
47
65
  };
48
-
49
66
  state.patches.push(patch);
50
67
  }
51
68
 
@@ -54,14 +71,24 @@ export function generateAddPatch(state: RecorderState<any>, path: (string | numb
54
71
  */
55
72
  export function generateReplacePatch(
56
73
  state: RecorderState<any>,
57
- path: (string | number)[],
58
- value: any,
74
+ path: PatchPath,
75
+ value: unknown,
76
+ oldValue?: unknown,
59
77
  ) {
60
- const patch = {
78
+ const patch: Patch = {
61
79
  op: Operation.Replace,
62
- path: formatPath(path, state.options),
80
+ path: path,
63
81
  value: cloneIfNeeded(value),
64
82
  };
65
83
 
84
+ // Add id if getItemId is configured for this path
85
+ const getItemIdFn = findGetItemIdFn(path, state.options.getItemId);
86
+ if (getItemIdFn && oldValue !== undefined) {
87
+ const id = getItemIdFn(oldValue);
88
+ if (id !== undefined && id !== null) {
89
+ patch.id = id;
90
+ }
91
+ }
92
+
66
93
  state.patches.push(patch);
67
94
  }
package/src/proxy.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type {RecorderState} from './types.js';
1
+ import type {PatchPath, RecorderState} from './types.js';
2
2
  import {generateSetPatch, generateDeletePatch, generateAddPatch} from './patches.js';
3
3
  import {isArray, isMap, isSet} from './utils.js';
4
4
  import {handleArrayGet} from './arrays.js';
@@ -7,9 +7,15 @@ import {handleSetGet} from './sets.js';
7
7
 
8
8
  export function createProxy<T extends object>(
9
9
  target: T,
10
- path: (string | number)[],
10
+ path: PatchPath,
11
11
  state: RecorderState<any>,
12
12
  ): T {
13
+ // Check cache first
14
+ const cached = state.proxyCache.get(target);
15
+ if (cached) {
16
+ return cached;
17
+ }
18
+
13
19
  const isArrayType = isArray(target);
14
20
  const isMapType = isMap(target);
15
21
  const isSetType = isSet(target);
@@ -39,12 +45,8 @@ export function createProxy<T extends object>(
39
45
  return value;
40
46
  }
41
47
 
42
- // Create nested proxy for draftable values
43
- // Only include string | number in path, skip symbols
44
- if (typeof prop === 'string' || typeof prop === 'number') {
45
- return createProxy(value, [...path, prop], state);
46
- }
47
- return value;
48
+ // Create nested proxy for all draftable values
49
+ return createProxy(value, [...path, prop], state);
48
50
  },
49
51
 
50
52
  set(obj, prop, value) {
@@ -55,50 +57,35 @@ export function createProxy<T extends object>(
55
57
 
56
58
  const oldValue = (obj as any)[prop];
57
59
 
58
- // Only create path for string | number props, skip symbols
59
- if (typeof prop !== 'string' && typeof prop !== 'number') {
60
- (obj as any)[prop] = value;
61
- return true;
62
- }
63
-
64
60
  // Convert numeric string props to numbers for array indices
65
61
  const propForPath = typeof prop === 'string' && !isNaN(Number(prop)) ? Number(prop) : prop;
66
62
  const propPath = [...path, propForPath];
67
63
 
68
- // Skip if no actual change (handle undefined as a valid value)
69
- if (
70
- oldValue === value &&
71
- (value !== undefined || Object.prototype.hasOwnProperty.call(obj, prop))
72
- ) {
73
- return true;
74
- }
64
+ // Check if property actually exists (for no-op detection)
65
+ const actuallyHasProperty = Object.prototype.hasOwnProperty.call(obj, prop);
75
66
 
76
- // Determine if this is an add or replace operation by checking the original state
77
- let originalHasProperty = false;
78
- let originalValue = undefined;
79
-
80
- // Navigate to the original object at this path
81
- let currentOriginal = state.original as any;
82
- for (let i = 0; i < path.length; i++) {
83
- currentOriginal = currentOriginal[path[i]];
84
- if (currentOriginal === undefined || currentOriginal === null) {
85
- break;
86
- }
67
+ // For add vs replace distinction: check array bounds for arrays
68
+ // Index within bounds = replace, out of bounds = add
69
+ let hadProperty = actuallyHasProperty;
70
+ if (isArrayType && typeof propForPath === 'number') {
71
+ hadProperty = propForPath >= 0 && propForPath < (obj as any[]).length;
87
72
  }
88
73
 
89
- if (currentOriginal && currentOriginal !== undefined && currentOriginal !== null) {
90
- originalHasProperty = Object.prototype.hasOwnProperty.call(currentOriginal, prop);
91
- originalValue = currentOriginal[prop];
74
+ // Skip if no actual change (handle undefined as a valid value)
75
+ // Use Object.is to correctly handle NaN (NaN !== NaN, but Object.is(NaN, NaN) === true)
76
+ // Use actuallyHasProperty for no-op detection (sparse array hole is different from undefined)
77
+ if (Object.is(oldValue, value) && (value !== undefined || actuallyHasProperty)) {
78
+ return true;
92
79
  }
93
80
 
94
81
  // Mutate original immediately
95
82
  (obj as any)[prop] = value;
96
83
 
97
- // Generate patch
98
- if (!originalHasProperty) {
84
+ // Generate patch - use pre-mutation property existence check
85
+ if (!hadProperty) {
99
86
  generateAddPatch(state, propPath, value);
100
87
  } else {
101
- generateSetPatch(state, propPath, originalValue, value);
88
+ generateSetPatch(state, propPath, oldValue, value);
102
89
  }
103
90
 
104
91
  return true;
@@ -116,13 +103,6 @@ export function createProxy<T extends object>(
116
103
  }
117
104
 
118
105
  const oldValue = (obj as any)[prop];
119
-
120
- // Only create path for string | number props, skip symbols
121
- if (typeof prop !== 'string' && typeof prop !== 'number') {
122
- delete (obj as any)[prop];
123
- return true;
124
- }
125
-
126
106
  const propPath = [...path, prop];
127
107
 
128
108
  if (oldValue !== undefined || Object.prototype.hasOwnProperty.call(obj, prop)) {
@@ -159,5 +139,10 @@ export function createProxy<T extends object>(
159
139
  },
160
140
  };
161
141
 
162
- return new Proxy(target, handler);
142
+ const proxy = new Proxy(target, handler);
143
+
144
+ // Store in cache
145
+ state.proxyCache.set(target, proxy);
146
+
147
+ return proxy;
163
148
  }
package/src/sets.ts CHANGED
@@ -1,29 +1,28 @@
1
- import type {RecorderState} from './types.js';
2
- import {createProxy} from './proxy.js';
3
- import {Operation} from './types.js';
1
+ import type {PatchPath, RecorderState} from './types.js';
4
2
  import {generateAddPatch, generateDeletePatch} from './patches.js';
5
- import {cloneIfNeeded, isSet, isArray} from './utils.js';
3
+ import {cloneIfNeeded} from './utils.js';
6
4
 
7
5
  /**
8
6
  * Handle property access on Set objects
9
7
  * Wraps mutating methods (add, delete, clear) to generate patches
10
8
  */
11
- export function handleSetGet<T = any>(
12
- obj: Set<T>,
9
+ export function handleSetGet(
10
+ obj: Set<any>,
13
11
  prop: string | symbol,
14
- path: (string | number)[],
12
+ path: PatchPath,
15
13
  state: RecorderState<any>,
16
14
  ): any {
17
- // Skip symbol properties
15
+ // Handle symbol properties - return the property value directly
16
+ // Symbol methods like Symbol.iterator should work normally
18
17
  if (typeof prop === 'symbol') {
19
18
  return (obj as any)[prop];
20
19
  }
21
20
 
22
21
  // Mutating methods
23
22
  if (prop === 'add') {
24
- return (value: T) => {
25
- // Check if value existed BEFORE mutation
26
- const existed = valueExistsInOriginal(state.original, path, value);
23
+ return (value: any) => {
24
+ // Check if value exists BEFORE mutation (current state, not original)
25
+ const existed = obj.has(value);
27
26
  const result = obj.add(value);
28
27
 
29
28
  // Generate patch only if value didn't exist
@@ -37,7 +36,7 @@ export function handleSetGet<T = any>(
37
36
  }
38
37
 
39
38
  if (prop === 'delete') {
40
- return (value: T) => {
39
+ return (value: any) => {
41
40
  const existed = obj.has(value);
42
41
  const result = obj.delete(value);
43
42
 
@@ -80,21 +79,3 @@ export function handleSetGet<T = any>(
80
79
  return (obj as any)[prop];
81
80
  }
82
81
 
83
- /**
84
- * Navigate to the original Set at the given path and check if a value exists
85
- * This is needed to check if a value existed before mutations
86
- */
87
- function valueExistsInOriginal(original: any, path: (string | number)[], value: any): boolean {
88
- let current = original;
89
- for (const part of path) {
90
- if (current == null) return false;
91
- current = current[part];
92
- }
93
-
94
- // If we reached a Set, check if the value exists
95
- if (current instanceof Set) {
96
- return current.has(value);
97
- }
98
-
99
- return false;
100
- }