How to observe property value changes of a third party object?

1k views Asked by At

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

2

There are 2 answers

3
david_adler On BEST ANSWER

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.

9
Peter Seliger On

@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; }