Search code examples
javascriptarraysjavascript-objectsgetter-setteriife

How to observe changes to contents of an object's array property exposed through setter-getter or Proxy


Using getter/setter

I'm creating an IIFE like below. It returns getters and setters to an array variable stored internally. I wish to intercept changes made to that array - the console.log is intended to indicate that in the setter below.

const a = (function() {
  let arr = [];

  return {
      get arr() {return arr},
      set arr(v) {
          console.log("new arr", v);
          arr = v;
      },
  }
})();

This works fine if I completely reassign arr, e.g. a.arr = [1, 2].

But it doesn't intercept changes made to contents of the array, e.g. a.arr.push(3) or a.arr.shift().

Looking for any ideas on how to intercept these content changes.

Using Proxy

This is an alternate attempt using the new Proxy object:

a = (function() {

    let details = {
        arr: []
    }

    function onChangeProxy(object, onChange) {
        const handler = {
            apply: function (target, thisArg, argumentsList) {
                onChange(thisArg, argumentsList);
                return thisArg[target].apply(this, argumentsList);
            },
            defineProperty: function (target, property, descriptor) {
                Reflect.defineProperty(target, property, descriptor);
                onChange(property, descriptor);
                return true;
            },
            deleteProperty: function(target, property) {
                Reflect.deleteProperty(target, property);
                onChange(property, descriptor);
                return;
            }
        };

        return new Proxy(object, handler);
    };

    return onChangeProxy(details, (p, d) => console.log(p, d));

})();

The problem remains the same. Still unable to observe changes made to the contents of a.arr using anything from a.arr[0] = 1 to a.push(3).


Solution

  • Update: The elegant solution (courtesy Kris Pruden and Sindre Sorhus)

    The solution is based on this commit by Kris on Sindre's 'on-change' library.

    Explanation of the solution, by Kris:

    In the set trap, my goal is to determine if the value provided is a Proxy produced by a previous call to the get trap. If it is such a Proxy, any property access will be intercepted by our own get trap. So, when I access value[proxyTarget] our get trap will be invoked, which is coded to return target when property === proxyTarget (line 46). If the value passed to set is not an on-change-created Proxy, then value[proxyTarget] is undefined.

    Full code of the solution:

    (object, onChange) => {
        let inApply = false;
        let changed = false;
    
        function handleChange() {
            if (!inApply) {
                onChange();
            } else if (!changed) {
                changed = true;
            }
        }
    
        const handler = {
            get(target, property, receiver) {
                const descriptor = Reflect.getOwnPropertyDescriptor(target, property);
                const value = Reflect.get(target, property, receiver);
    
                // Preserve invariants
                if (descriptor && !descriptor.configurable) {
                    if (descriptor.set && !descriptor.get) {
                        return undefined;
                    }
                    if (descriptor.writable === false) {
                        return value;
                    }
                }
    
                try {
                    return new Proxy(value, handler);
                } catch (_) {
                    return value;
                }
            },
            set(target, property, value) {
                const result = Reflect.set(target, property, value);
    
                handleChange();
    
                return result;
            },
            defineProperty(target, property, descriptor) {
                const result = Reflect.defineProperty(target, property, descriptor);
    
                handleChange();
    
                return result;
            },
            deleteProperty(target, property) {
                const result = Reflect.deleteProperty(target, property);
    
                handleChange();
    
                return result;
            },
            apply(target, thisArg, argumentsList) {
                if (!inApply) {
                    inApply = true;
                    const result = Reflect.apply(target, thisArg, argumentsList);
                    if (changed) {
                        onChange();
                    }
                    inApply = false;
                    changed = false;
                    return result;
                }
    
                return Reflect.apply(target, thisArg, argumentsList);
            }
        };
    
        return new Proxy(object, handler);
    };
    

    This has solved my problem, without resorting to the hack of checking for array modifying methods.


    Original solution:

    I've sorted this, for now, with help from David Walsh's post here. It's still ugly, but works for now.

    Updated the onChanged Proxy maker with a recursive-ish get trap.

    get: function (target, property, receiver) {
        let retval;
    
        try {
            retval = new Proxy(target[property], handler);
        } catch (err) {
            retval = Reflect.get(target, property, receiver);
        }
    
        if (mutators.includes(property))
            onChange(target, property, receiver);
    
        return retval;
    },
    

    Also added a list of functions to check the get trap against (the ugly, hacky bit):

    const mutators = [
        "push",
        "pop",
        "shift",
        "unshift",
        "splice",
        "reverse",
        "fill",
        "sort"
    ]
    

    This seems to be working in my testing so far.

    Thanks for pointing in the correct direction.