patch-recorder 0.1.0 → 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/README.md +1 -75
- package/dist/arrays.d.ts +2 -2
- package/dist/arrays.d.ts.map +1 -1
- package/dist/arrays.js +12 -20
- package/dist/arrays.js.map +1 -1
- package/dist/index.d.ts +1 -24
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -34
- package/dist/index.js.map +1 -1
- package/dist/maps.d.ts +2 -2
- package/dist/maps.d.ts.map +1 -1
- package/dist/maps.js +4 -20
- package/dist/maps.js.map +1 -1
- package/dist/optimizer.d.ts +16 -1
- package/dist/optimizer.d.ts.map +1 -1
- package/dist/optimizer.js +114 -15
- package/dist/optimizer.js.map +1 -1
- package/dist/patches.d.ts +5 -5
- package/dist/patches.d.ts.map +1 -1
- package/dist/patches.js +5 -5
- package/dist/patches.js.map +1 -1
- package/dist/proxy.d.ts +2 -2
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +24 -52
- package/dist/proxy.js.map +1 -1
- package/dist/sets.d.ts +2 -2
- package/dist/sets.d.ts.map +1 -1
- package/dist/sets.js +4 -20
- package/dist/sets.js.map +1 -1
- package/dist/types.d.ts +14 -33
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +28 -13
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +76 -20
- package/dist/utils.js.map +1 -1
- package/package.json +2 -1
- package/src/arrays.ts +22 -30
- package/src/index.ts +9 -54
- package/src/maps.ts +12 -30
- package/src/optimizer.ts +146 -28
- package/src/patches.ts +20 -21
- package/src/proxy.ts +31 -60
- package/src/sets.ts +11 -30
- package/src/types.ts +16 -40
- package/src/utils.ts +81 -28
package/src/optimizer.ts
CHANGED
|
@@ -1,20 +1,123 @@
|
|
|
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
|
-
*
|
|
5
|
-
|
|
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
|
|
6
35
|
*/
|
|
7
|
-
|
|
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.
|
|
109
|
+
*/
|
|
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:
|
|
14
|
-
const pathMap = new Map<string,
|
|
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 =
|
|
120
|
+
const pathKey = pathToKey(patch.path);
|
|
18
121
|
const existing = pathMap.get(pathKey);
|
|
19
122
|
|
|
20
123
|
if (!existing) {
|
|
@@ -49,16 +152,31 @@ export function compressPatches(patches: Patches<true>): Patches<true> {
|
|
|
49
152
|
// Cancel patches that are beyond array bounds after final length update
|
|
50
153
|
finalPatches = cancelOutOfBoundsPatches(finalPatches);
|
|
51
154
|
|
|
52
|
-
return finalPatches
|
|
155
|
+
return finalPatches;
|
|
53
156
|
}
|
|
54
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
|
+
|
|
55
170
|
/**
|
|
56
171
|
* Cancel array push + pop operations
|
|
57
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.
|
|
58
176
|
*/
|
|
59
|
-
function cancelArrayPushPop(patches: Patches
|
|
177
|
+
function cancelArrayPushPop(patches: Patches): Patches {
|
|
60
178
|
// Group patches by parent array path
|
|
61
|
-
const arrayGroups = new Map<string, Patches
|
|
179
|
+
const arrayGroups = new Map<string, Patches>();
|
|
62
180
|
|
|
63
181
|
for (const patch of patches) {
|
|
64
182
|
if (!Array.isArray(patch.path) || patch.path.length < 2) {
|
|
@@ -66,7 +184,7 @@ function cancelArrayPushPop(patches: Patches<true>): Patches<true> {
|
|
|
66
184
|
}
|
|
67
185
|
|
|
68
186
|
const parentPath = patch.path.slice(0, -1);
|
|
69
|
-
const parentKey =
|
|
187
|
+
const parentKey = pathToKey(parentPath);
|
|
70
188
|
|
|
71
189
|
if (!arrayGroups.has(parentKey)) {
|
|
72
190
|
arrayGroups.set(parentKey, []);
|
|
@@ -74,15 +192,15 @@ function cancelArrayPushPop(patches: Patches<true>): Patches<true> {
|
|
|
74
192
|
arrayGroups.get(parentKey)!.push(patch);
|
|
75
193
|
}
|
|
76
194
|
|
|
77
|
-
|
|
195
|
+
// Use WeakSet to track cancelable patches by reference (no string allocation)
|
|
196
|
+
const cancelablePatches = new WeakSet<Patch>();
|
|
78
197
|
|
|
79
198
|
for (const [, groupPatches] of arrayGroups.entries()) {
|
|
80
199
|
// Find push patches (add at highest indices)
|
|
81
200
|
const pushPatches = groupPatches
|
|
82
201
|
.filter((p) => p.op === 'add' && typeof p.path[p.path.length - 1] === 'number')
|
|
83
202
|
.sort(
|
|
84
|
-
(a, b) =>
|
|
85
|
-
(b.path[b.path.length - 1] as number) - (a.path[a.path.length - 1] as number),
|
|
203
|
+
(a, b) => (b.path[b.path.length - 1] as number) - (a.path[a.path.length - 1] as number),
|
|
86
204
|
);
|
|
87
205
|
|
|
88
206
|
// Find pop patches (length reduction)
|
|
@@ -103,24 +221,21 @@ function cancelArrayPushPop(patches: Patches<true>): Patches<true> {
|
|
|
103
221
|
// If push added at index pushIndex and pop reduced to popLength, they cancel
|
|
104
222
|
// This is a heuristic: push adds at end, pop removes from end
|
|
105
223
|
if (pushIndex >= popLength) {
|
|
106
|
-
cancelablePatches.add(
|
|
107
|
-
cancelablePatches.add(
|
|
224
|
+
cancelablePatches.add(pushPatch);
|
|
225
|
+
cancelablePatches.add(popPatch);
|
|
108
226
|
}
|
|
109
227
|
}
|
|
110
228
|
}
|
|
111
229
|
|
|
112
|
-
return patches.filter(
|
|
113
|
-
(patch) => !cancelablePatches.has(JSON.stringify(patch.path)),
|
|
114
|
-
) as Patches<true>;
|
|
230
|
+
return patches.filter((patch) => !cancelablePatches.has(patch));
|
|
115
231
|
}
|
|
116
232
|
|
|
117
233
|
/**
|
|
118
234
|
* Cancel patches that are beyond array bounds after final length update
|
|
119
235
|
*/
|
|
120
|
-
function cancelOutOfBoundsPatches(patches: Patches
|
|
236
|
+
function cancelOutOfBoundsPatches(patches: Patches): Patches {
|
|
121
237
|
// Find the final length for each array
|
|
122
238
|
const arrayLengths = new Map<string, number>();
|
|
123
|
-
const canceledPatches = new Set<string>();
|
|
124
239
|
|
|
125
240
|
for (const patch of patches) {
|
|
126
241
|
if (
|
|
@@ -128,11 +243,14 @@ function cancelOutOfBoundsPatches(patches: Patches<true>): Patches<true> {
|
|
|
128
243
|
patch.path.length >= 2 &&
|
|
129
244
|
patch.path[patch.path.length - 1] === 'length'
|
|
130
245
|
) {
|
|
131
|
-
const parentPath =
|
|
246
|
+
const parentPath = pathToKey(patch.path.slice(0, -1));
|
|
132
247
|
arrayLengths.set(parentPath, patch.value as number);
|
|
133
248
|
}
|
|
134
249
|
}
|
|
135
250
|
|
|
251
|
+
// Use WeakSet to track canceled patches by reference (no string allocation)
|
|
252
|
+
const canceledPatches = new WeakSet<Patch>();
|
|
253
|
+
|
|
136
254
|
// Cancel patches at indices >= final length
|
|
137
255
|
for (const patch of patches) {
|
|
138
256
|
if (!Array.isArray(patch.path) || patch.path.length < 2) {
|
|
@@ -140,26 +258,26 @@ function cancelOutOfBoundsPatches(patches: Patches<true>): Patches<true> {
|
|
|
140
258
|
}
|
|
141
259
|
|
|
142
260
|
const lastPath = patch.path[patch.path.length - 1];
|
|
143
|
-
const parentPath =
|
|
261
|
+
const parentPath = pathToKey(patch.path.slice(0, -1));
|
|
144
262
|
|
|
145
263
|
if (typeof lastPath === 'number' && arrayLengths.has(parentPath)) {
|
|
146
264
|
const length = arrayLengths.get(parentPath)!;
|
|
147
265
|
if (lastPath >= length) {
|
|
148
|
-
canceledPatches.add(
|
|
266
|
+
canceledPatches.add(patch);
|
|
149
267
|
}
|
|
150
268
|
}
|
|
151
269
|
}
|
|
152
270
|
|
|
153
|
-
return patches.filter(
|
|
154
|
-
(patch) => !canceledPatches.has(JSON.stringify(patch.path)),
|
|
155
|
-
) as Patches<true>;
|
|
271
|
+
return patches.filter((patch) => !canceledPatches.has(patch));
|
|
156
272
|
}
|
|
157
273
|
|
|
274
|
+
// ==================== Patch merging logic (shared) ====================
|
|
275
|
+
|
|
158
276
|
/**
|
|
159
277
|
* Merge two patches on the same path
|
|
160
278
|
* Returns the merged patch, or null if they cancel out, or undefined if they can't be merged
|
|
161
279
|
*/
|
|
162
|
-
function mergePatches(patch1:
|
|
280
|
+
function mergePatches(patch1: Patch, patch2: Patch): Patch | null | undefined {
|
|
163
281
|
const op1 = patch1.op;
|
|
164
282
|
const op2 = patch2.op;
|
|
165
283
|
|
package/src/patches.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
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 {
|
|
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<
|
|
10
|
-
path:
|
|
11
|
-
oldValue:
|
|
12
|
-
newValue:
|
|
9
|
+
state: RecorderState<NonPrimitive>,
|
|
10
|
+
path: PatchPath,
|
|
11
|
+
oldValue: unknown,
|
|
12
|
+
newValue: unknown,
|
|
13
13
|
) {
|
|
14
14
|
const patch: any = {
|
|
15
15
|
op: Operation.Replace,
|
|
16
|
-
path
|
|
16
|
+
path,
|
|
17
17
|
value: cloneIfNeeded(newValue),
|
|
18
18
|
};
|
|
19
19
|
|
|
@@ -33,13 +33,13 @@ export function generateSetPatch(
|
|
|
33
33
|
* Generate a remove patch for property deletions
|
|
34
34
|
*/
|
|
35
35
|
export function generateDeletePatch(
|
|
36
|
-
state: RecorderState<
|
|
37
|
-
path:
|
|
38
|
-
oldValue:
|
|
36
|
+
state: RecorderState<NonPrimitive>,
|
|
37
|
+
path: PatchPath,
|
|
38
|
+
oldValue: unknown,
|
|
39
39
|
) {
|
|
40
|
-
const patch:
|
|
40
|
+
const patch: Patch = {
|
|
41
41
|
op: Operation.Remove,
|
|
42
|
-
path:
|
|
42
|
+
path: path,
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
// Add id if getItemId is configured for this path
|
|
@@ -57,13 +57,12 @@ export function generateDeletePatch(
|
|
|
57
57
|
/**
|
|
58
58
|
* Generate an add patch for new properties
|
|
59
59
|
*/
|
|
60
|
-
export function generateAddPatch(state: RecorderState<any>, path:
|
|
61
|
-
const patch = {
|
|
60
|
+
export function generateAddPatch(state: RecorderState<any>, path: PatchPath, value: any) {
|
|
61
|
+
const patch: Patch = {
|
|
62
62
|
op: Operation.Add,
|
|
63
|
-
path
|
|
63
|
+
path,
|
|
64
64
|
value: cloneIfNeeded(value),
|
|
65
65
|
};
|
|
66
|
-
|
|
67
66
|
state.patches.push(patch);
|
|
68
67
|
}
|
|
69
68
|
|
|
@@ -72,13 +71,13 @@ export function generateAddPatch(state: RecorderState<any>, path: (string | numb
|
|
|
72
71
|
*/
|
|
73
72
|
export function generateReplacePatch(
|
|
74
73
|
state: RecorderState<any>,
|
|
75
|
-
path:
|
|
76
|
-
value:
|
|
77
|
-
oldValue?:
|
|
74
|
+
path: PatchPath,
|
|
75
|
+
value: unknown,
|
|
76
|
+
oldValue?: unknown,
|
|
78
77
|
) {
|
|
79
|
-
const patch:
|
|
78
|
+
const patch: Patch = {
|
|
80
79
|
op: Operation.Replace,
|
|
81
|
-
path:
|
|
80
|
+
path: path,
|
|
82
81
|
value: cloneIfNeeded(value),
|
|
83
82
|
};
|
|
84
83
|
|
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:
|
|
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
|
-
|
|
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,64 +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
|
-
//
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
Object.is(oldValue, value) &&
|
|
72
|
-
(value !== undefined || Object.prototype.hasOwnProperty.call(obj, prop))
|
|
73
|
-
) {
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
64
|
+
// Check if property actually exists (for no-op detection)
|
|
65
|
+
const actuallyHasProperty = Object.prototype.hasOwnProperty.call(obj, prop);
|
|
76
66
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
let
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
let currentOriginal = state.original as any;
|
|
83
|
-
for (let i = 0; i < path.length; i++) {
|
|
84
|
-
currentOriginal = currentOriginal[path[i]];
|
|
85
|
-
if (currentOriginal === undefined || currentOriginal === null) {
|
|
86
|
-
break;
|
|
87
|
-
}
|
|
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;
|
|
88
72
|
}
|
|
89
73
|
|
|
90
|
-
if
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (typeof index === 'number') {
|
|
96
|
-
originalHasProperty = index >= 0 && index < currentOriginal.length;
|
|
97
|
-
originalValue = (currentOriginal as any)[index];
|
|
98
|
-
} else {
|
|
99
|
-
originalHasProperty = Object.prototype.hasOwnProperty.call(currentOriginal, prop);
|
|
100
|
-
originalValue = (currentOriginal as any)[prop];
|
|
101
|
-
}
|
|
102
|
-
} else {
|
|
103
|
-
originalHasProperty = Object.prototype.hasOwnProperty.call(currentOriginal, prop);
|
|
104
|
-
originalValue = (currentOriginal as any)[prop];
|
|
105
|
-
}
|
|
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;
|
|
106
79
|
}
|
|
107
80
|
|
|
108
81
|
// Mutate original immediately
|
|
109
82
|
(obj as any)[prop] = value;
|
|
110
83
|
|
|
111
|
-
// Generate patch
|
|
112
|
-
if (!
|
|
84
|
+
// Generate patch - use pre-mutation property existence check
|
|
85
|
+
if (!hadProperty) {
|
|
113
86
|
generateAddPatch(state, propPath, value);
|
|
114
87
|
} else {
|
|
115
|
-
generateSetPatch(state, propPath,
|
|
88
|
+
generateSetPatch(state, propPath, oldValue, value);
|
|
116
89
|
}
|
|
117
90
|
|
|
118
91
|
return true;
|
|
@@ -130,13 +103,6 @@ export function createProxy<T extends object>(
|
|
|
130
103
|
}
|
|
131
104
|
|
|
132
105
|
const oldValue = (obj as any)[prop];
|
|
133
|
-
|
|
134
|
-
// Only create path for string | number props, skip symbols
|
|
135
|
-
if (typeof prop !== 'string' && typeof prop !== 'number') {
|
|
136
|
-
delete (obj as any)[prop];
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
106
|
const propPath = [...path, prop];
|
|
141
107
|
|
|
142
108
|
if (oldValue !== undefined || Object.prototype.hasOwnProperty.call(obj, prop)) {
|
|
@@ -173,5 +139,10 @@ export function createProxy<T extends object>(
|
|
|
173
139
|
},
|
|
174
140
|
};
|
|
175
141
|
|
|
176
|
-
|
|
142
|
+
const proxy = new Proxy(target, handler);
|
|
143
|
+
|
|
144
|
+
// Store in cache
|
|
145
|
+
state.proxyCache.set(target, proxy);
|
|
146
|
+
|
|
147
|
+
return proxy;
|
|
177
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
|
|
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
|
|
12
|
-
obj: Set<
|
|
9
|
+
export function handleSetGet(
|
|
10
|
+
obj: Set<any>,
|
|
13
11
|
prop: string | symbol,
|
|
14
|
-
path:
|
|
12
|
+
path: PatchPath,
|
|
15
13
|
state: RecorderState<any>,
|
|
16
14
|
): any {
|
|
17
|
-
//
|
|
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:
|
|
25
|
-
// Check if value
|
|
26
|
-
const existed =
|
|
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:
|
|
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
|
-
}
|
package/src/types.ts
CHANGED
|
@@ -6,19 +6,6 @@ export const Operation = {
|
|
|
6
6
|
|
|
7
7
|
export type PatchOp = (typeof Operation)[keyof typeof Operation];
|
|
8
8
|
|
|
9
|
-
export type PatchesOptions =
|
|
10
|
-
| boolean
|
|
11
|
-
| {
|
|
12
|
-
/**
|
|
13
|
-
* The default value is `true`. If it's `true`, the path will be an array, otherwise it is a string.
|
|
14
|
-
*/
|
|
15
|
-
pathAsArray?: boolean;
|
|
16
|
-
/**
|
|
17
|
-
* The default value is `true`. If it's `true`, the array length will be included in the patches, otherwise no include array length.
|
|
18
|
-
*/
|
|
19
|
-
arrayLengthAssignment?: boolean;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
9
|
/**
|
|
23
10
|
* Function that extracts an ID from an item value
|
|
24
11
|
*/
|
|
@@ -31,7 +18,10 @@ export type GetItemIdConfig = {
|
|
|
31
18
|
[key: string]: GetItemIdFunction | GetItemIdConfig;
|
|
32
19
|
};
|
|
33
20
|
|
|
34
|
-
export
|
|
21
|
+
export type PatchPath = (string | number | symbol | object)[];
|
|
22
|
+
|
|
23
|
+
export type Patch = {
|
|
24
|
+
path: PatchPath;
|
|
35
25
|
op: PatchOp;
|
|
36
26
|
value?: any;
|
|
37
27
|
/**
|
|
@@ -39,31 +29,13 @@ export interface IPatch {
|
|
|
39
29
|
* Populated when getItemId option is configured for the item's parent path.
|
|
40
30
|
*/
|
|
41
31
|
id?: string | number;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export type Patch<P extends PatchesOptions = true> = P extends {
|
|
45
|
-
pathAsArray: false;
|
|
46
|
-
}
|
|
47
|
-
? IPatch & {
|
|
48
|
-
path: string;
|
|
49
|
-
}
|
|
50
|
-
: P extends true | object
|
|
51
|
-
? IPatch & {
|
|
52
|
-
path: (string | number)[];
|
|
53
|
-
}
|
|
54
|
-
: IPatch & {
|
|
55
|
-
path: string | (string | number)[];
|
|
56
|
-
};
|
|
32
|
+
};
|
|
57
33
|
|
|
58
|
-
export type Patches
|
|
34
|
+
export type Patches = Patch[];
|
|
59
35
|
|
|
60
36
|
export type NonPrimitive = object | Array<unknown>;
|
|
61
37
|
|
|
62
38
|
export interface RecordPatchesOptions {
|
|
63
|
-
/**
|
|
64
|
-
* Return paths as arrays (default: true) or strings
|
|
65
|
-
*/
|
|
66
|
-
pathAsArray?: boolean;
|
|
67
39
|
/**
|
|
68
40
|
* Include array length in patches (default: true)
|
|
69
41
|
*/
|
|
@@ -92,11 +64,15 @@ export interface RecordPatchesOptions {
|
|
|
92
64
|
getItemId?: GetItemIdConfig;
|
|
93
65
|
}
|
|
94
66
|
|
|
95
|
-
export type Draft<T> = T;
|
|
67
|
+
export type Draft<T extends NonPrimitive> = T;
|
|
96
68
|
|
|
97
|
-
export interface RecorderState<T> {
|
|
98
|
-
|
|
99
|
-
patches: Patches
|
|
100
|
-
basePath:
|
|
101
|
-
options: RecordPatchesOptions
|
|
69
|
+
export interface RecorderState<T extends NonPrimitive> {
|
|
70
|
+
state: T;
|
|
71
|
+
patches: Patches;
|
|
72
|
+
basePath: PatchPath;
|
|
73
|
+
options: RecordPatchesOptions;
|
|
74
|
+
/**
|
|
75
|
+
* Cache for proxies to avoid creating new ones on repeated property access
|
|
76
|
+
*/
|
|
77
|
+
proxyCache: WeakMap<object, any>;
|
|
102
78
|
}
|