-
Notifications
You must be signed in to change notification settings - Fork 13
/
jsonpatcherproxy.js
440 lines (421 loc) · 16.8 KB
/
jsonpatcherproxy.js
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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
'use strict';
/*!
* https://github.com/Palindrom/JSONPatcherProxy
* (c) 2017 Starcounter
* MIT license
*
* Vocabulary used in this file:
* * root - root object that is deeply observed by JSONPatcherProxy
* * tree - any subtree within the root or the root
*/
/** Class representing a JS Object observer */
const JSONPatcherProxy = (function() {
/**
* Deep clones your object and returns a new object.
*/
function deepClone(obj) {
switch (typeof obj) {
case 'object':
return JSON.parse(JSON.stringify(obj)); //Faster than ES5 clone - http://jsperf.com/deep-cloning-of-objects/5
case 'undefined':
return null; //this is how JSON.stringify behaves for array items
default:
return obj; //no need to clone primitives
}
}
JSONPatcherProxy.deepClone = deepClone;
function escapePathComponent(str) {
if (str.indexOf('/') == -1 && str.indexOf('~') == -1) return str;
return str.replace(/~/g, '~0').replace(/\//g, '~1');
}
JSONPatcherProxy.escapePathComponent = escapePathComponent;
/**
* Walk up the parenthood tree to get the path
* @param {JSONPatcherProxy} instance
* @param {Object} tree the object you need to find its path
*/
function getPathToTree(instance, tree) {
const pathComponents = [];
let parenthood = instance._parenthoodMap.get(tree);
while (parenthood && parenthood.key) {
// because we're walking up-tree, we need to use the array as a stack
pathComponents.unshift(parenthood.key);
parenthood = instance._parenthoodMap.get(parenthood.parent);
}
if (pathComponents.length) {
const path = pathComponents.join('/');
return '/' + path;
}
return '';
}
/**
* A callback to be used as the proxy set trap callback.
* It updates parenthood map if needed, proxifies nested newly-added objects, calls default callback with the changes occurred.
* @param {JSONPatcherProxy} instance JSONPatcherProxy instance
* @param {Object} tree the affected object
* @param {String} key the effect property's name
* @param {Any} newValue the value being set
*/
function trapForSet(instance, tree, key, newValue) {
const pathToKey = getPathToTree(instance, tree) + '/' + escapePathComponent(key);
const subtreeMetadata = instance._treeMetadataMap.get(newValue);
if (instance._treeMetadataMap.has(newValue)) {
instance._parenthoodMap.set(subtreeMetadata.originalObject, { parent: tree, key });
}
/*
mark already proxified values as inherited.
rationale: proxy.arr.shift()
will emit
{op: replace, path: '/arr/1', value: arr_2}
{op: remove, path: '/arr/2'}
by default, the second operation would revoke the proxy, and this renders arr revoked.
That's why we need to remember the proxies that are inherited.
*/
/*
Why do we need to check instance._isProxifyingTreeNow?
We need to make sure we mark revocables as inherited ONLY when we're observing,
because throughout the first proxification, a sub-object is proxified and then assigned to
its parent object. This assignment of a pre-proxified object can fool us into thinking
that it's a proxified object moved around, while in fact it's the first assignment ever.
Checking _isProxifyingTreeNow ensures this is not happening in the first proxification,
but in fact is is a proxified object moved around the tree
*/
if (subtreeMetadata && !instance._isProxifyingTreeNow) {
subtreeMetadata.inherited = true;
}
let warnedAboutNonIntegrerArrayProp = false;
const isTreeAnArray = Array.isArray(tree);
const isNonSerializableArrayProperty = isTreeAnArray && !Number.isInteger(+key.toString());
// if the new value is an object, make sure to watch it
if (
newValue &&
typeof newValue == 'object' &&
!instance._treeMetadataMap.has(newValue)
) {
if (isNonSerializableArrayProperty) {
// This happens in Vue 1-2 (should not happen in Vue 3). See: https://github.com/vuejs/vue/issues/427, https://github.com/vuejs/vue/issues/9259
console.warn(`JSONPatcherProxy noticed a non-integer property ('${key}') was set for an array. This interception will not emit a patch. The value is an object, but it was not proxified, because it would not be addressable in JSON-Pointer`);
warnedAboutNonIntegrerArrayProp = true;
}
else {
instance._parenthoodMap.set(newValue, { parent: tree, key });
newValue = instance._proxifyTreeRecursively(tree, newValue, key);
}
}
// let's start with this operation, and may or may not update it later
const valueBeforeReflection = tree[key];
const wasKeyInTreeBeforeReflection = tree.hasOwnProperty(key);
if (isTreeAnArray && !isNonSerializableArrayProperty) {
const index = parseInt(key, 10);
if (index > tree.length) {
// force call trapForSet for implicit undefined elements of the array added by the JS engine
// because JSON-Patch spec prohibits adding an index that is higher than array.length
trapForSet(instance, tree, (index - 1) + '', undefined);
}
}
const reflectionResult = Reflect.set(tree, key, newValue);
const operation = {
op: 'remove',
path: pathToKey
};
if (typeof newValue == 'undefined') {
// applying De Morgan's laws would be a tad faster, but less readable
if (!isTreeAnArray && !wasKeyInTreeBeforeReflection) {
// `undefined` is being set to an already undefined value, keep silent
return reflectionResult;
} else {
if (wasKeyInTreeBeforeReflection && !isSignificantChange(valueBeforeReflection, newValue, isTreeAnArray)) {
return reflectionResult; // Value wasn't actually changed with respect to its JSON projection
}
// when array element is set to `undefined`, should generate replace to `null`
if (isTreeAnArray) {
operation.value = null;
if (wasKeyInTreeBeforeReflection) {
operation.op = 'replace';
}
else {
operation.op = 'add';
}
}
const oldSubtreeMetadata = instance._treeMetadataMap.get(valueBeforeReflection);
if (oldSubtreeMetadata) {
//TODO there is no test for this!
instance._parenthoodMap.delete(valueBeforeReflection);
instance._disableTrapsForTreeMetadata(oldSubtreeMetadata);
instance._treeMetadataMap.delete(oldSubtreeMetadata);
}
}
} else {
if (isNonSerializableArrayProperty) {
/* array props (as opposed to indices) don't emit any patches, to avoid needless `length` patches */
if(key != 'length' && !warnedAboutNonIntegrerArrayProp) {
console.warn(`JSONPatcherProxy noticed a non-integer property ('${key}') was set for an array. This interception will not emit a patch`);
}
return reflectionResult;
}
operation.op = 'add';
if (wasKeyInTreeBeforeReflection) {
if (typeof valueBeforeReflection !== 'undefined' || isTreeAnArray) {
if (!isSignificantChange(valueBeforeReflection, newValue, isTreeAnArray)) {
return reflectionResult; // Value wasn't actually changed with respect to its JSON projection
}
operation.op = 'replace'; // setting `undefined` array elements is a `replace` op
}
}
operation.value = newValue;
}
instance._defaultCallback(operation);
return reflectionResult;
}
/**
* Test if replacing old value with new value is a significant change, i.e. whether or not
* it soiuld result in a patch being generated.
* @param {*} oldValue old value
* @param {*} newValue new value
* @param {boolean} isTreeAnArray value resides in an array
*/
function isSignificantChange(oldValue, newValue, isTreeAnArray) {
if (isTreeAnArray) {
return isSignificantChangeInArray(oldValue, newValue);
} else {
return isSignificantChangeInObject(oldValue, newValue);
}
}
/**
* Test if replacing old value with new value is a significant change in an object, i.e.
* whether or not it should result in a patch being generated.
* @param {*} oldValue old value
* @param {*} newValue new value
*/
function isSignificantChangeInObject(oldValue, newValue) {
return oldValue !== newValue;
}
/**
* Test if replacing old value with new value is a significant change in an array, i.e.
* whether or not it should result in a patch being generated.
* @param {*} oldValue old value
* @param {*} newValue new value
*/
function isSignificantChangeInArray(oldValue, newValue) {
if (typeof oldValue === 'undefined') {
oldValue = null;
}
if (typeof newValue === 'undefined') {
newValue = null;
}
return oldValue !== newValue;
}
/**
* A callback to be used as the proxy delete trap callback.
* It updates parenthood map if needed, calls default callbacks with the changes occurred.
* @param {JSONPatcherProxy} instance JSONPatcherProxy instance
* @param {Object} tree the effected object
* @param {String} key the effected property's name
*/
function trapForDeleteProperty(instance, tree, key) {
const oldValue = tree[key];
const reflectionResult = Reflect.deleteProperty(tree, key);
if (typeof oldValue !== 'undefined') {
const pathToKey = getPathToTree(instance, tree) + '/' + escapePathComponent(key);
const subtreeMetadata = instance._treeMetadataMap.get(oldValue);
if (subtreeMetadata) {
if (subtreeMetadata.inherited) {
/*
this is an inherited proxy (an already proxified object that was moved around),
we shouldn't revoke it, because even though it was removed from path1, it is still used in path2.
And we know that because we mark moved proxies with `inherited` flag when we move them
it is a good idea to remove this flag if we come across it here, in trapForDeleteProperty.
We DO want to revoke the proxy if it was removed again.
*/
subtreeMetadata.inherited = false;
} else {
instance._parenthoodMap.delete(subtreeMetadata.originalObject);
instance._disableTrapsForTreeMetadata(subtreeMetadata);
instance._treeMetadataMap.delete(oldValue);
}
}
instance._defaultCallback({
op: 'remove',
path: pathToKey
});
}
return reflectionResult;
}
/**
* Creates an instance of JSONPatcherProxy around your object of interest `root`.
* @param {Object|Array} root - the object you want to wrap
* @param {Boolean} [showDetachedWarning = true] - whether to log a warning when a detached sub-object is modified @see {@link https://github.com/Palindrom/JSONPatcherProxy#detached-objects}
* @returns {JSONPatcherProxy}
* @constructor
*/
function JSONPatcherProxy(root, showDetachedWarning) {
this._isProxifyingTreeNow = false;
this._isObserving = false;
this._treeMetadataMap = new Map();
this._parenthoodMap = new Map();
// default to true
if (typeof showDetachedWarning !== 'boolean') {
showDetachedWarning = true;
}
this._showDetachedWarning = showDetachedWarning;
this._originalRoot = root;
this._cachedProxy = null;
this._isRecording = false;
this._userCallback;
this._defaultCallback;
this._patches;
}
JSONPatcherProxy.prototype._generateProxyAtKey = function(parent, tree, key) {
if (!tree) {
return tree;
}
const handler = {
set: (...args) => trapForSet(this, ...args),
deleteProperty: (...args) => trapForDeleteProperty(this, ...args)
};
const treeMetadata = Proxy.revocable(tree, handler);
// cache the object that contains traps to disable them later.
treeMetadata.handler = handler;
treeMetadata.originalObject = tree;
/* keeping track of the object's parent and the key within the parent */
this._parenthoodMap.set(tree, { parent, key });
/* keeping track of all the proxies to be able to revoke them later */
this._treeMetadataMap.set(treeMetadata.proxy, treeMetadata);
return treeMetadata.proxy;
};
// grab tree's leaves one by one, encapsulate them into a proxy and return
JSONPatcherProxy.prototype._proxifyTreeRecursively = function(parent, tree, key) {
for (let key in tree) {
if (tree.hasOwnProperty(key)) {
if (tree[key] instanceof Object) {
tree[key] = this._proxifyTreeRecursively(
tree,
tree[key],
escapePathComponent(key)
);
}
}
}
return this._generateProxyAtKey(parent, tree, key);
};
// this function is for aesthetic purposes
JSONPatcherProxy.prototype._proxifyRoot = function(root) {
/*
while proxifying object tree,
the proxifying operation itself is being
recorded, which in an unwanted behavior,
that's why we disable recording through this
initial process;
*/
this.pause();
this._isProxifyingTreeNow = true;
const proxifiedRoot = this._proxifyTreeRecursively(
undefined,
root,
''
);
/* OK you can record now */
this._isProxifyingTreeNow = false;
this.resume();
return proxifiedRoot;
};
/**
* Turns a proxified object into a forward-proxy object; doesn't emit any patches anymore, like a normal object
* @param {Object} treeMetadata
*/
JSONPatcherProxy.prototype._disableTrapsForTreeMetadata = function(treeMetadata) {
if (this._showDetachedWarning) {
const message =
"You're accessing an object that is detached from the observedObject tree, see https://github.com/Palindrom/JSONPatcherProxy#detached-objects";
treeMetadata.handler.set = (
parent,
key,
newValue
) => {
console.warn(message);
return Reflect.set(parent, key, newValue);
};
treeMetadata.handler.set = (
parent,
key,
newValue
) => {
console.warn(message);
return Reflect.set(parent, key, newValue);
};
treeMetadata.handler.deleteProperty = (
parent,
key
) => {
return Reflect.deleteProperty(parent, key);
};
} else {
delete treeMetadata.handler.set;
delete treeMetadata.handler.get;
delete treeMetadata.handler.deleteProperty;
}
};
/**
* Proxifies the object that was passed in the constructor and returns a proxified mirror of it. Even though both parameters are options. You need to pass at least one of them.
* @param {Boolean} [record] - whether to record object changes to a later-retrievable patches array.
* @param {Function} [callback] - this will be synchronously called with every object change with a single `patch` as the only parameter.
*/
JSONPatcherProxy.prototype.observe = function(record, callback) {
if (!record && !callback) {
throw new Error('You need to either record changes or pass a callback');
}
this._isRecording = record;
this._userCallback = callback;
/*
I moved it here to remove it from `unobserve`,
this will also make the constructor faster, why initiate
the array before they decide to actually observe with recording?
They might need to use only a callback.
*/
if (record) this._patches = [];
this._cachedProxy = this._proxifyRoot(this._originalRoot);
return this._cachedProxy;
};
/**
* If the observed is set to record, it will synchronously return all the patches and empties patches array.
*/
JSONPatcherProxy.prototype.generate = function() {
if (!this._isRecording) {
throw new Error('You should set record to true to get patches later');
}
return this._patches.splice(0, this._patches.length);
};
/**
* Revokes all proxies, rendering the observed object useless and good for garbage collection @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/revocable}
*/
JSONPatcherProxy.prototype.revoke = function() {
this._treeMetadataMap.forEach(el => {
el.revoke();
});
};
/**
* Disables all proxies' traps, turning the observed object into a forward-proxy object, like a normal object that you can modify silently.
*/
JSONPatcherProxy.prototype.disableTraps = function() {
this._treeMetadataMap.forEach(this._disableTrapsForTreeMetadata, this);
};
/**
* Restores callback back to the original one provided to `observe`.
*/
JSONPatcherProxy.prototype.resume = function() {
this._defaultCallback = operation => {
this._isRecording && this._patches.push(operation);
this._userCallback && this._userCallback(operation);
};
this._isObserving = true;
};
/**
* Replaces callback with a noop function.
*/
JSONPatcherProxy.prototype.pause = function() {
this._defaultCallback = () => {};
this._isObserving = false;
}
return JSONPatcherProxy;
})();
export { JSONPatcherProxy };