5

I would like to observe whenever a property of a third party object is changed. I'm taking the approach of assigning a custom setter but my console.log below is never invoked. Why is that? Is there a better approach?

const foo = { a: 1, b: 2 };

Object.assign(foo, {
  set user(user) {
    foo.user = user;
    console.log(">>>>>> user was changed", user);
  },
});

// Desired behaviour
foo.user = "asdf"; // >>>>>> user was changed asdf
delete foo.user; // >>>>>> user was changed undefined
foo.user = "asdf1" // >>>>>> user was changed asdf1

Please note, I need to mutate foo I cannot wrap a proxy around foo and return that because it is a third party library which mutates .user internally

david_adler
  • 9,690
  • 6
  • 57
  • 97

2 Answers2

4

I've found a way, pretty hacky as it is

const foo = { a: 1, b: 2 };

let underlyingValue = foo.user

Object.defineProperty(foo, "user", {
  get() {
    return underlyingValue
  },
  set(user) {
    underlyingValue = user;
    console.log(">>>>>> user was changed", user);
  },
  enumerable: true
});

foo.user = "asdf";
console.log(foo)

I've made this into a generic function below

/** Intercepts writes to any property of an object */
function observeProperty(obj, property, onChanged) {
  const oldDescriptor = Object.getOwnPropertyDescriptor(obj, property);
  let val = obj[property];
  Object.defineProperty(obj, property, {
    get() {
      return val;
    },
    set(newVal) {
      val = newVal;
      onChanged(newVal);
    },
    enumerable: oldDescriptor?.enumerable,
    configurable: oldDescriptor?.configurable,
  });
}

// example usage 
const foo = { a: 1 };
observeProperty(foo, "a", (a) => {
  console.log("a was changed to", a);
});
foo.a = 2; // a was changed to  2

Also available in typescript

Edit: This will break if the property is deleted eg delete foo.user. The observer will be removed and the callback will stop firing. You will need to re-attach it.

david_adler
  • 9,690
  • 6
  • 57
  • 97
  • Indeed *"... pretty hacky as it is."* ... In this case `user` is not enumerable like `a` and `b`. And how about observing other properties than just `user`. Is the latter a special case or does the OP need a somehow more generic observation approach? – Peter Seliger Nov 29 '21 at 10:32
  • 1
    `foo` is not a property you might need to re-word your comment? – david_adler Nov 29 '21 at 10:33
  • 1
    Good point I'll make it enumerable. As for a generic approach yeah that's in the collapsed snippet – david_adler Nov 29 '21 at 10:41
1

@david_adler ... when I commented ...

"Is the latter a special case or does the OP need a somehow more generic observation approach?"

... I thought of the most generic solution one could come up with in terms of changing/mutating an existing object entirely into an observable variant of itself.

Such a solution also would be more close to what the OP did ask for ...

"I would like to observe whenever a property of a third party object is changed"

Thus the next provided approach keeps the objects appearance and behavior and also does not introduce additional (e.g. Symbol based) keys.

function mutateIntoObservableZombie(obj, handlePropertyChange) {
  const propertyMap = new Map;

  function createAccessors(keyOrSymbol, initialValue, handler) {
    return {
      set (value) {
        propertyMap.set(keyOrSymbol, value);
        handler(keyOrSymbol, value, this);
        return value;
      },
      get () {
        return propertyMap.has(keyOrSymbol)
          ? propertyMap.get(keyOrSymbol)
          : initialValue;
      },
    };
  }
  function wrapSet(keyOrSymbol, proceed, handler) {
    return function set (value) {
      handler(keyOrSymbol, value, this);
      return proceed.call(this, value);
    };
  }
  function createAndAssignObservableDescriptor([keyOrSymbol, descriptor]) {
    const { value, get, set, writable, ...descr } = descriptor;

    if (isFunction(set)) {
      descr.get = get;
      descr.set = wrapSet(keyOrSymbol, set, handlePropertyChange);
    }
    if (descriptor.hasOwnProperty('value')) {
      Object.assign(descr, createAccessors(keyOrSymbol, value, handlePropertyChange));
    }
    Object.defineProperty(obj, keyOrSymbol, descr);
  }
  const isFunction = value => (typeof value === 'function');

  if (isFunction(handlePropertyChange)) {
    const ownDescriptors = Object.getOwnPropertyDescriptors(obj);
    const ownDescrSymbols = Object.getOwnPropertySymbols(ownDescriptors);

    Object
      .entries(ownDescriptors)
      .forEach(createAndAssignObservableDescriptor);

    ownDescrSymbols
      .forEach(symbol =>
        createAndAssignObservableDescriptor([symbol, ownDescriptors[symbol]])
      );
  }
  return obj;
}


// third party object (closed/inaccessible code)
const foo = { a: 1, b: 2 };


// custom changes already.
foo.userName = '';
foo.userLoginName = '';

const userNick = Symbol('nickname');

foo[userNick] = null;

console.log('`foo` before descriptor change ...', { foo });

mutateIntoObservableZombie(foo, (key, value, target) => {
  console.log('property change ...', { key, value, target });
});
console.log('`foo` after descriptor change ...', { foo });

foo.a = "foo bar";
foo.b = "baz biz";

console.log('`foo` after property change ...', { foo });

foo.userName = '****';
foo.userLoginName = '************@**********';

console.log('`foo` after property change ...', { foo });

foo[userNick] = 'superuser';

console.log('`foo` after symbol property change ...', { foo });
.as-console-wrapper { min-height: 100%!important; top: 0; }

Edit

Since the above approach already is implemented generic and modular it of cause easily can be refactored into a function which allows the exact definition of which property/ies, both string and symbol based, are going to be observed ...

function observePropertyChange(obj, keysAndSymbols, handlePropertyChange) {
  const propertyMap = new Map;

  function createAccessors(keyOrSymbol, initialValue, handler) {
    return {
      set (value) {
        propertyMap.set(keyOrSymbol, value);
        handler(keyOrSymbol, value, this);
        return value;
      },
      get () {
        return propertyMap.has(keyOrSymbol)
          ? propertyMap.get(keyOrSymbol)
          : initialValue;
      },
    };
  }
  function wrapSet(keyOrSymbol, proceed, handler) {
    return function set (value) {
      handler(keyOrSymbol, value, this);
      return proceed.call(this, value);
    };
  }
  function createAndAssignObservableDescriptor(keyOrSymbol, descriptor) {
    const { value, get, set, writable, ...descr } = descriptor;

    if (isFunction(set)) {
      descr.get = get;
      descr.set = wrapSet(keyOrSymbol, set, handlePropertyChange);
    }
    if (descriptor.hasOwnProperty('value')) {
      Object.assign(descr, createAccessors(keyOrSymbol, value, handlePropertyChange));
    }
    Object.defineProperty(obj, keyOrSymbol, descr);
  }
  const isString = value => (typeof value === 'string');
  const isSymbol = value => (typeof value === 'symbol');
  const isFunction = value => (typeof value === 'function');

  if (isFunction(handlePropertyChange)) {

    const ownDescriptors = Object.getOwnPropertyDescriptors(obj);
    const identifierList = (Array
      .isArray(keysAndSymbols) && keysAndSymbols || [keysAndSymbols])
      .filter(identifier => isString(identifier) || isSymbol(identifier));

    identifierList
      .forEach(keyOrSymbol =>
        createAndAssignObservableDescriptor(keyOrSymbol, ownDescriptors[keyOrSymbol])
      );
  }
  return obj;
}


// third party object (closed/inaccessible code)
const foo = { a: 1, b: 2 };


// custom changes already.
foo.userName = '';
foo.userLoginName = '';

const userNick = Symbol('nickname');

foo[userNick] = null;

console.log('`foo` before descriptor change ...', { foo });

observePropertyChange(
  foo,
  ['b', 'userLoginName', userNick],
  (key, value, target) => { console.log('property change ...', { key, value, target }); },
);
console.log('`foo` after descriptor change ...', { foo });

foo.a = "foo bar";
foo.b = "baz biz";

console.log('`foo` after property change ...', { foo });

foo.userName = '****';
foo.userLoginName = '************@**********';

console.log('`foo` after property change ...', { foo });

foo[userNick] = 'superuser';

console.log('`foo` after symbol property change ...', { foo });
.as-console-wrapper { min-height: 100%!important; top: 0; }
Peter Seliger
  • 11,747
  • 3
  • 28
  • 37
  • @david_adler ... when I commented ... *"Is the latter a special case or does the OP need a somehow more generic observation approach?"* ... I thought of the most generic solution one could come up with in terms of changing/mutating an existing object entirely into an observable variant of itself. Such a solution also would be more close to what the OP did ask for ... *"I would like to observe whenever a property of a third party object is changed"* ... Thus the above provided approach keeps the objects appearance and behavior and also does not introduce additional (e.g. `Symbol` based) keys. – Peter Seliger Nov 29 '21 at 13:47
  • yeah good approach, no need for the symbol property, have updated my implementation to be more similar to yours. However, in my particular use case, I don't need to observe all properties so I would prefer not to deal with the complexities of observing methods etc. – david_adler Nov 29 '21 at 13:59
  • @david_adler ... in this case you might consider being more precise with the description and the title ( _**"Observe any property of an object"**_ ). – Peter Seliger Nov 29 '21 at 14:02
  • cool, done that now. I think you're answer may prove useful anyway for other future googlers – david_adler Nov 29 '21 at 14:16
  • One issue that this doesn't solve (and nor does my above solution) is that the observability is broken if you delete the property e.g `delete foo.a; foo.a = "asdf"` – david_adler Nov 30 '21 at 11:02
  • @david_adler ... Correct, one needs to make a decision. For the mutated observable object one needs to decide, either leaving its appearance/behavior as is, or whether one wants to reassign each observable property explicitly via its descriptor as `configurable: false` which would prevent deleting it. – Peter Seliger Nov 30 '21 at 12:15
  • Yeah but a better solution would be (a) inheriting configurable from original object and (b) being able to detect deletions and preserving the observer in that case. Whether or not it's possible is the question! – david_adler Nov 30 '21 at 12:46
  • 1
    @david_adler ... I can't think of any alternatives. That's why I presented my last comment as final choice. From what we came up with, I think it's as far as one could go with a property descriptor based approach. – Peter Seliger Nov 30 '21 at 13:07
  • I played around with some ideas of Object.assign a Proxy onto the object and or modifying the underlying prototype but I didn't get too far. I still have faith it's possible – david_adler Nov 30 '21 at 13:45