|
| 1 | +'use strict'; |
| 2 | +/*! |
| 3 | + * https://github.com/Palindrom/JSONPatcherProxy |
| 4 | + * JSONPatcherProxy version: 0.0.8 |
| 5 | + * (c) 2017 Starcounter |
| 6 | + * MIT license |
| 7 | + */ |
| 8 | + |
| 9 | +/** Class representing a JS Object observer */ |
| 10 | +const JSONPatcherProxy = (function() { |
| 11 | + /** |
| 12 | + * Deep clones your object and returns a new object. |
| 13 | + */ |
| 14 | + function deepClone(obj) { |
| 15 | + switch (typeof obj) { |
| 16 | + case 'object': |
| 17 | + return JSON.parse(JSON.stringify(obj)); //Faster than ES5 clone - http://jsperf.com/deep-cloning-of-objects/5 |
| 18 | + case 'undefined': |
| 19 | + return null; //this is how JSON.stringify behaves for array items |
| 20 | + default: |
| 21 | + return obj; //no need to clone primitives |
| 22 | + } |
| 23 | + } |
| 24 | + JSONPatcherProxy.deepClone = deepClone; |
| 25 | + |
| 26 | + function escapePathComponent(str) { |
| 27 | + if (str.indexOf('/') == -1 && str.indexOf('~') == -1) return str; |
| 28 | + return str.replace(/~/g, '~0').replace(/\//g, '~1'); |
| 29 | + } |
| 30 | + JSONPatcherProxy.escapePathComponent = escapePathComponent; |
| 31 | + |
| 32 | + /** |
| 33 | + * Walk up the parenthood tree to get the path |
| 34 | + * @param {JSONPatcherProxy} instance |
| 35 | + * @param {Object} obj the object you need to find its path |
| 36 | + */ |
| 37 | + function findObjectPath(instance, obj) { |
| 38 | + const pathComponents = []; |
| 39 | + let parentAndPath = instance.parenthoodMap.get(obj); |
| 40 | + while (parentAndPath && parentAndPath.path) { |
| 41 | + // because we're walking up-tree, we need to use the array as a stack |
| 42 | + pathComponents.unshift(parentAndPath.path); |
| 43 | + parentAndPath = instance.parenthoodMap.get(parentAndPath.parent); |
| 44 | + } |
| 45 | + if (pathComponents.length) { |
| 46 | + const path = pathComponents.join('/'); |
| 47 | + return '/' + path; |
| 48 | + } |
| 49 | + return ''; |
| 50 | + } |
| 51 | + /** |
| 52 | + * A callback to be used as th proxy set trap callback. |
| 53 | + * It updates parenthood map if needed, proxifies nested newly-added objects, calls default callbacks with the changes occurred. |
| 54 | + * @param {JSONPatcherProxy} instance JSONPatcherProxy instance |
| 55 | + * @param {Object} target the affected object |
| 56 | + * @param {String} key the effect property's name |
| 57 | + * @param {Any} newValue the value being set |
| 58 | + */ |
| 59 | + function setTrap(instance, target, key, newValue) { |
| 60 | + const parentPath = findObjectPath(instance, target); |
| 61 | + |
| 62 | + const destinationPropKey = parentPath + '/' + escapePathComponent(key); |
| 63 | + |
| 64 | + if (instance.proxifiedObjectsMap.has(newValue)) { |
| 65 | + const newValueOriginalObject = instance.proxifiedObjectsMap.get(newValue); |
| 66 | + |
| 67 | + instance.parenthoodMap.set(newValueOriginalObject.originalObject, { |
| 68 | + parent: target, |
| 69 | + path: key |
| 70 | + }); |
| 71 | + } |
| 72 | + /* |
| 73 | + mark already proxified values as inherited. |
| 74 | + rationale: proxy.arr.shift() |
| 75 | + will emit |
| 76 | + {op: replace, path: '/arr/1', value: arr_2} |
| 77 | + {op: remove, path: '/arr/2'} |
| 78 | +
|
| 79 | + by default, the second operation would revoke the proxy, and this renders arr revoked. |
| 80 | + That's why we need to remember the proxies that are inherited. |
| 81 | + */ |
| 82 | + const revokableInstance = instance.proxifiedObjectsMap.get(newValue); |
| 83 | + /* |
| 84 | + Why do we need to check instance.isProxifyingTreeNow? |
| 85 | +
|
| 86 | + We need to make sure we mark revokables as inherited ONLY when we're observing, |
| 87 | + because throughout the first proxification, a sub-object is proxified and then assigned to |
| 88 | + its parent object. This assignment of a pre-proxified object can fool us into thinking |
| 89 | + that it's a proxified object moved around, while in fact it's the first assignment ever. |
| 90 | +
|
| 91 | + Checking isProxifyingTreeNow ensures this is not happening in the first proxification, |
| 92 | + but in fact is is a proxified object moved around the tree |
| 93 | + */ |
| 94 | + if (revokableInstance && !instance.isProxifyingTreeNow) { |
| 95 | + revokableInstance.inherited = true; |
| 96 | + } |
| 97 | + |
| 98 | + // if the new value is an object, make sure to watch it |
| 99 | + if ( |
| 100 | + newValue && |
| 101 | + typeof newValue == 'object' && |
| 102 | + !instance.proxifiedObjectsMap.has(newValue) |
| 103 | + ) { |
| 104 | + instance.parenthoodMap.set(newValue, { |
| 105 | + parent: target, |
| 106 | + path: key |
| 107 | + }); |
| 108 | + newValue = instance._proxifyObjectTreeRecursively(target, newValue, key); |
| 109 | + } |
| 110 | + // let's start with this operation, and may or may not update it later |
| 111 | + const operation = { |
| 112 | + op: 'remove', |
| 113 | + path: destinationPropKey |
| 114 | + }; |
| 115 | + if (typeof newValue == 'undefined') { |
| 116 | + // applying De Morgan's laws would be a tad faster, but less readable |
| 117 | + if (!Array.isArray(target) && !target.hasOwnProperty(key)) { |
| 118 | + // `undefined` is being set to an already undefined value, keep silent |
| 119 | + return Reflect.set(target, key, newValue); |
| 120 | + } else { |
| 121 | + // when array element is set to `undefined`, should generate replace to `null` |
| 122 | + if (Array.isArray(target)) { |
| 123 | + // undefined array elements are JSON.stringified to `null` |
| 124 | + (operation.op = 'replace'), (operation.value = null); |
| 125 | + } |
| 126 | + const oldValue = instance.proxifiedObjectsMap.get(target[key]); |
| 127 | + // was the deleted a proxified object? |
| 128 | + if (oldValue) { |
| 129 | + instance.parenthoodMap.delete(target[key]); |
| 130 | + instance.disableTrapsForProxy(oldValue); |
| 131 | + instance.proxifiedObjectsMap.delete(oldValue); |
| 132 | + } |
| 133 | + } |
| 134 | + } else { |
| 135 | + if (Array.isArray(target) && !Number.isInteger(+key.toString())) { |
| 136 | + /* array props (as opposed to indices) don't emit any patches, to avoid needless `length` patches */ |
| 137 | + return Reflect.set(target, key, newValue); |
| 138 | + } |
| 139 | + operation.op = 'add'; |
| 140 | + if (target.hasOwnProperty(key)) { |
| 141 | + if (typeof target[key] !== 'undefined' || Array.isArray(target)) { |
| 142 | + operation.op = 'replace'; // setting `undefined` array elements is a `replace` op |
| 143 | + } |
| 144 | + } |
| 145 | + operation.value = newValue; |
| 146 | + } |
| 147 | + const reflectionResult = Reflect.set(target, key, newValue); |
| 148 | + instance.defaultCallback(operation); |
| 149 | + return reflectionResult; |
| 150 | + } |
| 151 | + /** |
| 152 | + * A callback to be used as th proxy delete trap callback. |
| 153 | + * It updates parenthood map if needed, calls default callbacks with the changes occurred. |
| 154 | + * @param {JSONPatcherProxy} instance JSONPatcherProxy instance |
| 155 | + * @param {Object} target the effected object |
| 156 | + * @param {String} key the effected property's name |
| 157 | + */ |
| 158 | + function deleteTrap(instance, target, key) { |
| 159 | + if (typeof target[key] !== 'undefined') { |
| 160 | + const parentPath = findObjectPath(instance, target); |
| 161 | + const destinationPropKey = parentPath + '/' + escapePathComponent(key); |
| 162 | + |
| 163 | + const revokableProxyInstance = instance.proxifiedObjectsMap.get( |
| 164 | + target[key] |
| 165 | + ); |
| 166 | + |
| 167 | + if (revokableProxyInstance) { |
| 168 | + if (revokableProxyInstance.inherited) { |
| 169 | + /* |
| 170 | + this is an inherited proxy (an already proxified object that was moved around), |
| 171 | + we shouldn't revoke it, because even though it was removed from path1, it is still used in path2. |
| 172 | + And we know that because we mark moved proxies with `inherited` flag when we move them |
| 173 | +
|
| 174 | + it is a good idea to remove this flag if we come across it here, in deleteProperty trap. |
| 175 | + We DO want to revoke the proxy if it was removed again. |
| 176 | + */ |
| 177 | + revokableProxyInstance.inherited = false; |
| 178 | + } else { |
| 179 | + instance.parenthoodMap.delete(revokableProxyInstance.originalObject); |
| 180 | + instance.disableTrapsForProxy(revokableProxyInstance); |
| 181 | + instance.proxifiedObjectsMap.delete(target[key]); |
| 182 | + } |
| 183 | + } |
| 184 | + const reflectionResult = Reflect.deleteProperty(target, key); |
| 185 | + |
| 186 | + instance.defaultCallback({ |
| 187 | + op: 'remove', |
| 188 | + path: destinationPropKey |
| 189 | + }); |
| 190 | + |
| 191 | + return reflectionResult; |
| 192 | + } |
| 193 | + } |
| 194 | + /* pre-define resume and pause functions to enhance constructors performance */ |
| 195 | + function resume() { |
| 196 | + this.defaultCallback = operation => { |
| 197 | + this.isRecording && this.patches.push(operation); |
| 198 | + this.userCallback && this.userCallback(operation); |
| 199 | + }; |
| 200 | + this.isObserving = true; |
| 201 | + } |
| 202 | + function pause() { |
| 203 | + this.defaultCallback = () => {}; |
| 204 | + this.isObserving = false; |
| 205 | + } |
| 206 | + /** |
| 207 | + * Creates an instance of JSONPatcherProxy around your object of interest `root`. |
| 208 | + * @param {Object|Array} root - the object you want to wrap |
| 209 | + * @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} |
| 210 | + * @returns {JSONPatcherProxy} |
| 211 | + * @constructor |
| 212 | + */ |
| 213 | + function JSONPatcherProxy(root, showDetachedWarning) { |
| 214 | + this.isProxifyingTreeNow = false; |
| 215 | + this.isObserving = false; |
| 216 | + this.proxifiedObjectsMap = new Map(); |
| 217 | + this.parenthoodMap = new Map(); |
| 218 | + // default to true |
| 219 | + if (typeof showDetachedWarning !== 'boolean') { |
| 220 | + showDetachedWarning = true; |
| 221 | + } |
| 222 | + |
| 223 | + this.showDetachedWarning = showDetachedWarning; |
| 224 | + this.originalObject = root; |
| 225 | + this.cachedProxy = null; |
| 226 | + this.isRecording = false; |
| 227 | + this.userCallback; |
| 228 | + /** |
| 229 | + * @memberof JSONPatcherProxy |
| 230 | + * Restores callback back to the original one provided to `observe`. |
| 231 | + */ |
| 232 | + this.resume = resume.bind(this); |
| 233 | + /** |
| 234 | + * @memberof JSONPatcherProxy |
| 235 | + * Replaces your callback with a noop function. |
| 236 | + */ |
| 237 | + this.pause = pause.bind(this); |
| 238 | + } |
| 239 | + |
| 240 | + JSONPatcherProxy.prototype.generateProxyAtPath = function(parent, obj, path) { |
| 241 | + if (!obj) { |
| 242 | + return obj; |
| 243 | + } |
| 244 | + const traps = { |
| 245 | + set: (target, key, value, receiver) => |
| 246 | + setTrap(this, target, key, value, receiver), |
| 247 | + deleteProperty: (target, key) => deleteTrap(this, target, key) |
| 248 | + }; |
| 249 | + const revocableInstance = Proxy.revocable(obj, traps); |
| 250 | + // cache traps object to disable them later. |
| 251 | + revocableInstance.trapsInstance = traps; |
| 252 | + revocableInstance.originalObject = obj; |
| 253 | + |
| 254 | + /* keeping track of object's parent and path */ |
| 255 | + |
| 256 | + this.parenthoodMap.set(obj, { parent, path }); |
| 257 | + |
| 258 | + /* keeping track of all the proxies to be able to revoke them later */ |
| 259 | + this.proxifiedObjectsMap.set(revocableInstance.proxy, revocableInstance); |
| 260 | + return revocableInstance.proxy; |
| 261 | + }; |
| 262 | + // grab tree's leaves one by one, encapsulate them into a proxy and return |
| 263 | + JSONPatcherProxy.prototype._proxifyObjectTreeRecursively = function( |
| 264 | + parent, |
| 265 | + root, |
| 266 | + path |
| 267 | + ) { |
| 268 | + for (let key in root) { |
| 269 | + if (root.hasOwnProperty(key)) { |
| 270 | + if (root[key] instanceof Object) { |
| 271 | + root[key] = this._proxifyObjectTreeRecursively( |
| 272 | + root, |
| 273 | + root[key], |
| 274 | + escapePathComponent(key) |
| 275 | + ); |
| 276 | + } |
| 277 | + } |
| 278 | + } |
| 279 | + return this.generateProxyAtPath(parent, root, path); |
| 280 | + }; |
| 281 | + // this function is for aesthetic purposes |
| 282 | + JSONPatcherProxy.prototype.proxifyObjectTree = function(root) { |
| 283 | + /* |
| 284 | + while proxyifying object tree, |
| 285 | + the proxyifying operation itself is being |
| 286 | + recorded, which in an unwanted behavior, |
| 287 | + that's why we disable recording through this |
| 288 | + initial process; |
| 289 | + */ |
| 290 | + this.pause(); |
| 291 | + this.isProxifyingTreeNow = true; |
| 292 | + const proxifiedObject = this._proxifyObjectTreeRecursively( |
| 293 | + undefined, |
| 294 | + root, |
| 295 | + '' |
| 296 | + ); |
| 297 | + /* OK you can record now */ |
| 298 | + this.isProxifyingTreeNow = false; |
| 299 | + this.resume(); |
| 300 | + return proxifiedObject; |
| 301 | + }; |
| 302 | + /** |
| 303 | + * Turns a proxified object into a forward-proxy object; doesn't emit any patches anymore, like a normal object |
| 304 | + * @param {Proxy} proxy - The target proxy object |
| 305 | + */ |
| 306 | + JSONPatcherProxy.prototype.disableTrapsForProxy = function( |
| 307 | + revokableProxyInstance |
| 308 | + ) { |
| 309 | + if (this.showDetachedWarning) { |
| 310 | + const message = |
| 311 | + "You're accessing an object that is detached from the observedObject tree, see https://github.com/Palindrom/JSONPatcherProxy#detached-objects"; |
| 312 | + |
| 313 | + revokableProxyInstance.trapsInstance.set = ( |
| 314 | + targetObject, |
| 315 | + propKey, |
| 316 | + newValue |
| 317 | + ) => { |
| 318 | + console.warn(message); |
| 319 | + return Reflect.set(targetObject, propKey, newValue); |
| 320 | + }; |
| 321 | + revokableProxyInstance.trapsInstance.set = ( |
| 322 | + targetObject, |
| 323 | + propKey, |
| 324 | + newValue |
| 325 | + ) => { |
| 326 | + console.warn(message); |
| 327 | + return Reflect.set(targetObject, propKey, newValue); |
| 328 | + }; |
| 329 | + revokableProxyInstance.trapsInstance.deleteProperty = ( |
| 330 | + targetObject, |
| 331 | + propKey |
| 332 | + ) => { |
| 333 | + return Reflect.deleteProperty(targetObject, propKey); |
| 334 | + }; |
| 335 | + } else { |
| 336 | + delete revokableProxyInstance.trapsInstance.set; |
| 337 | + delete revokableProxyInstance.trapsInstance.get; |
| 338 | + delete revokableProxyInstance.trapsInstance.deleteProperty; |
| 339 | + } |
| 340 | + }; |
| 341 | + /** |
| 342 | + * 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. |
| 343 | + * @param {Boolean} [record] - whether to record object changes to a later-retrievable patches array. |
| 344 | + * @param {Function} [callback] - this will be synchronously called with every object change with a single `patch` as the only parameter. |
| 345 | + */ |
| 346 | + JSONPatcherProxy.prototype.observe = function(record, callback) { |
| 347 | + if (!record && !callback) { |
| 348 | + throw new Error('You need to either record changes or pass a callback'); |
| 349 | + } |
| 350 | + this.isRecording = record; |
| 351 | + this.userCallback = callback; |
| 352 | + /* |
| 353 | + I moved it here to remove it from `unobserve`, |
| 354 | + this will also make the constructor faster, why initiate |
| 355 | + the array before they decide to actually observe with recording? |
| 356 | + They might need to use only a callback. |
| 357 | + */ |
| 358 | + if (record) this.patches = []; |
| 359 | + this.cachedProxy = this.proxifyObjectTree(this.originalObject); |
| 360 | + return this.cachedProxy; |
| 361 | + }; |
| 362 | + /** |
| 363 | + * If the observed is set to record, it will synchronously return all the patches and empties patches array. |
| 364 | + */ |
| 365 | + JSONPatcherProxy.prototype.generate = function() { |
| 366 | + if (!this.isRecording) { |
| 367 | + throw new Error('You should set record to true to get patches later'); |
| 368 | + } |
| 369 | + return this.patches.splice(0, this.patches.length); |
| 370 | + }; |
| 371 | + /** |
| 372 | + * 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} |
| 373 | + */ |
| 374 | + JSONPatcherProxy.prototype.revoke = function() { |
| 375 | + this.proxifiedObjectsMap.forEach(el => { |
| 376 | + el.revoke(); |
| 377 | + }); |
| 378 | + }; |
| 379 | + /** |
| 380 | + * Disables all proxies' traps, turning the observed object into a forward-proxy object, like a normal object that you can modify silently. |
| 381 | + */ |
| 382 | + JSONPatcherProxy.prototype.disableTraps = function() { |
| 383 | + this.proxifiedObjectsMap.forEach(this.disableTrapsForProxy, this); |
| 384 | + }; |
| 385 | + return JSONPatcherProxy; |
| 386 | +})(); |
| 387 | + |
| 388 | +if (typeof module !== 'undefined') { |
| 389 | + module.exports = JSONPatcherProxy; |
| 390 | + module.exports.default = JSONPatcherProxy; |
| 391 | +} |
0 commit comments