/* eslint-disable unicorn/prefer-spread */ import {TARGET, UNSUBSCRIBE, PATH_SEPARATOR} from './lib/constants.js'; import {isBuiltinWithMutableMethods, isBuiltinWithoutMutableMethods} from './lib/is-builtin.js'; import path from './lib/path.js'; import isArray from './lib/is-array.js'; import isSymbol from './lib/is-symbol.js'; import isIterator from './lib/is-iterator.js'; import wrapIterator from './lib/wrap-iterator.js'; import ignoreProperty from './lib/ignore-property.js'; import Cache from './lib/cache.js'; import SmartClone from './lib/smart-clone/smart-clone.js'; const defaultOptions = { equals: Object.is, isShallow: false, pathAsArray: false, ignoreSymbols: false, ignoreUnderscores: false, ignoreDetached: false, details: false, }; const onChange = (object, onChange, options = {}) => { options = { ...defaultOptions, ...options, }; const proxyTarget = Symbol('ProxyTarget'); const {equals, isShallow, ignoreDetached, details} = options; const cache = new Cache(equals); const hasOnValidate = typeof options.onValidate === 'function'; const smartClone = new SmartClone(hasOnValidate); // eslint-disable-next-line max-params const validate = (target, property, value, previous, applyData) => !hasOnValidate || smartClone.isCloning || options.onValidate(path.concat(cache.getPath(target), property), value, previous, applyData) === true; const handleChangeOnTarget = (target, property, value, previous) => { if ( !ignoreProperty(cache, options, property) && !(ignoreDetached && cache.isDetached(target, object)) ) { handleChange(cache.getPath(target), property, value, previous); } }; // eslint-disable-next-line max-params const handleChange = (changePath, property, value, previous, applyData) => { if (smartClone.isCloning && smartClone.isPartOfClone(changePath)) { smartClone.update(changePath, property, previous); } else { onChange(path.concat(changePath, property), value, previous, applyData); } }; const getProxyTarget = value => value ? (value[proxyTarget] ?? value) : value; const prepareValue = (value, target, property, basePath) => { if ( isBuiltinWithoutMutableMethods(value) || property === 'constructor' || (isShallow && !SmartClone.isHandledMethod(target, property)) || ignoreProperty(cache, options, property) || cache.isGetInvariant(target, property) || (ignoreDetached && cache.isDetached(target, object)) ) { return value; } if (basePath === undefined) { basePath = cache.getPath(target); } /* Check for circular references. If the value already has a corresponding path/proxy, and if the path corresponds to one of the parents, then we are on a circular case, where the child is pointing to their parent. In this case we return the proxy object with the shortest path. */ const childPath = path.concat(basePath, property); const existingPath = cache.getPath(value); if (existingPath && isSameObjectTree(childPath, existingPath)) { // We are on the same object tree but deeper, so we use the parent path. return cache.getProxy(value, existingPath, handler, proxyTarget); } return cache.getProxy(value, childPath, handler, proxyTarget); }; /* Returns true if `childPath` is a subpath of `existingPath` (if childPath starts with existingPath). Otherwise, it returns false. It also returns false if the 2 paths are identical. For example: - childPath = group.layers.0.parent.layers.0.value - existingPath = group.layers.0.parent */ const isSameObjectTree = (childPath, existingPath) => { if (isSymbol(childPath) || childPath.length <= existingPath.length) { return false; } if (isArray(existingPath) && existingPath.length === 0) { return false; } const childParts = isArray(childPath) ? childPath : childPath.split(PATH_SEPARATOR); const existingParts = isArray(existingPath) ? existingPath : existingPath.split(PATH_SEPARATOR); if (childParts.length <= existingParts.length) { return false; } return !(existingParts.some((part, index) => part !== childParts[index])); }; const handler = { get(target, property, receiver) { if (isSymbol(property)) { if (property === proxyTarget || property === TARGET) { return target; } if ( property === UNSUBSCRIBE && !cache.isUnsubscribed && cache.getPath(target).length === 0 ) { cache.unsubscribe(); return target; } } const value = isBuiltinWithMutableMethods(target) ? Reflect.get(target, property) : Reflect.get(target, property, receiver); return prepareValue(value, target, property); }, set(target, property, value, receiver) { value = getProxyTarget(value); const reflectTarget = target[proxyTarget] ?? target; const previous = reflectTarget[property]; if (equals(previous, value) && property in target) { return true; } const isValid = validate(target, property, value, previous); if ( isValid && cache.setProperty(reflectTarget, property, value, receiver, previous) ) { handleChangeOnTarget(target, property, target[property], previous); return true; } return !isValid; }, defineProperty(target, property, descriptor) { if (!cache.isSameDescriptor(descriptor, target, property)) { const previous = target[property]; if ( validate(target, property, descriptor.value, previous) && cache.defineProperty(target, property, descriptor, previous) ) { handleChangeOnTarget(target, property, descriptor.value, previous); } } return true; }, deleteProperty(target, property) { if (!Reflect.has(target, property)) { return true; } const previous = Reflect.get(target, property); const isValid = validate(target, property, undefined, previous); if ( isValid && cache.deleteProperty(target, property, previous) ) { handleChangeOnTarget(target, property, undefined, previous); return true; } return !isValid; }, apply(target, thisArg, argumentsList) { const thisProxyTarget = thisArg[proxyTarget] ?? thisArg; if (cache.isUnsubscribed) { return Reflect.apply(target, thisProxyTarget, argumentsList); } if ( (details === false || (details !== true && !details.includes(target.name))) && SmartClone.isHandledType(thisProxyTarget) ) { let applyPath = path.initial(cache.getPath(target)); const isHandledMethod = SmartClone.isHandledMethod(thisProxyTarget, target.name); smartClone.start(thisProxyTarget, applyPath, argumentsList); let result = Reflect.apply( target, smartClone.preferredThisArg(target, thisArg, thisProxyTarget), isHandledMethod ? argumentsList.map(argument => getProxyTarget(argument)) : argumentsList, ); const isChanged = smartClone.isChanged(thisProxyTarget, equals); const previous = smartClone.stop(); if (SmartClone.isHandledType(result) && isHandledMethod) { if (thisArg instanceof Map && target.name === 'get') { applyPath = path.concat(applyPath, argumentsList[0]); } result = cache.getProxy(result, applyPath, handler); } if (isChanged) { const applyData = { name: target.name, args: argumentsList, result, }; const changePath = smartClone.isCloning ? path.initial(applyPath) : applyPath; const property = smartClone.isCloning ? path.last(applyPath) : ''; if (validate(path.get(object, changePath), property, thisProxyTarget, previous, applyData)) { handleChange(changePath, property, thisProxyTarget, previous, applyData); } else { smartClone.undo(thisProxyTarget); } } if ( (thisArg instanceof Map || thisArg instanceof Set) && isIterator(result) ) { return wrapIterator(result, target, thisArg, applyPath, prepareValue); } return result; } return Reflect.apply(target, thisArg, argumentsList); }, }; const proxy = cache.getProxy(object, options.pathAsArray ? [] : '', handler); onChange = onChange.bind(proxy); if (hasOnValidate) { options.onValidate = options.onValidate.bind(proxy); } return proxy; }; onChange.target = proxy => proxy?.[TARGET] ?? proxy; onChange.unsubscribe = proxy => proxy?.[UNSUBSCRIBE] ?? proxy; export default onChange;