0
\$\begingroup\$

The purpose of this code is add proxy on nested object for log changes on property. This is a debug utility!

  // check if value is object or array
  function isObject(value) {
    return value !== null && typeof value === "object" && ["Array", "Object"].includes(value.constructor.name);
  }
  
  // add nested proxy
  function buildProxy(poj, callback = console.log, tree = []) {
    const getPath = (prop) => tree.concat(prop).join(".");

    return new Proxy(poj, {
      get(target, prop) {
        const value = Reflect.get(target, prop);
        if (isObject(value)) {
          return buildProxy(value, callback, tree.concat(prop));
        }
        return value;
      },

      set(target, prop, value) {
        callback({
          action: "set",
          path: getPath(prop),
          newValue: value,
          preValue: Reflect.get(target, prop, value),
          stack: new Error().stack.split("\n")[1].trim()
        });
        return Reflect.set(target, prop, value);
      },

      deleteProperty(target, prop) {
        callback({ action: "delete", path: getPath(prop), stack: new Error().stack.split("\n")[1].trim() });
        return Reflect.deleteProperty(target, prop);
      },
    });
  }

  // remove nested proxy
  const unbuildProxy = (value) => {
    if (isObject(value)) {
      if (Array.isArray(value)) {
        value = [...value];
      } else {
        value = { ...value };
      }
      for (const key in value) {
        value[key] = unbuildProxy(value[key]);
      }
    }
    return value;
  }

  // set nested proxy on data
  let data = buildProxy(
    {
      name: "David",
      occupation: "freelancer",
      children: [{ name: "oliver" }, { name: "ruby" }],
      nested: { a: { b: true } }
    }
  );

  // test console.log on changes
  data.name = "Mike";
  data.children.push({ name: "baby" });
  data.children[1].name = "fred";
  delete data.occupation;
  data.nested.a.b = { c: true };
  data.nested.a.b.c = "Mike2";

  // remove nested proxy
  data = unbuildProxy(data)

  // structuredClone raise exception if object is proxy 
  console.log(structuredClone(data))

there are improvements?

\$\endgroup\$
0

1 Answer 1

3
\$\begingroup\$

Serious BUG!

Normally this would make the question "off topic", but unless you are experienced with JS this type of bug is very often overlooked or even considered in normal testing regimes.

The bug is very dangerous, that will either throw a call stack overflow or timeout the page.

You do not check created proxy objects for cyclic references.

As proxies are slow and depending on the structure of the proxy the page will often timeout before the call stack overflows.

Demo the bug

For example your testing code can be changed to

  data.a.b = {c: true};      // adds new proxy
  data.a.b.c = data.a;       // cyclic proxy reference
  data = unbuildProxy(data); // call stack overflow (or page timeout)

The call chain is not straight forward as its the unbuildProxy call that starts the infinite call chain.

See snippet below for example.

Fix

Most common solution is to use a WeakSet (only keeps the hash of objects) to track objects you create a proxy for. If the object exists in the map don't build a new proxy for it.

Another solution is to count the cycle depth and throw an error if it gets too deep. (Note at all ideal)

Example of bug

The snippet shows the bug by throwing an error when the call depth to buildProxy is > 50.

Open the console [hit F12] to see the trace when the snippet throws the error.

Note I made some minor mods (before I noticed the bug). Changes do not change codes behaviour (Apart from (fixed) unguarded for in loop in original code).

function isObject(value) {
  return value !== null && typeof value === "object" && ["Array", "Object"].includes(value.constructor.name);
}
function buildProxy(poj, callback = console.log, tree = [], count = 0) {
  if (count > 50) { throw new Error("Cycle depth too deep: " + count)      }
  const getPath = (prop) => tree.concat(prop).join(".");
  return new Proxy(poj, {
    get(target, prop) {
      const value = Reflect.get(target, prop);
      if (isObject(value)) { return buildProxy(value, callback, tree.concat(prop), count + 1); }
      return value;
    },
    set(target, prop, value) {
      callback({
        action: "set",
        path: getPath(prop),
        newValue: value,
        preValue: Reflect.get(target, prop, value),
        stack: new Error().stack.split("\n")[1].trim()
      });
      return Reflect.set(target, prop, value);
    },
    deleteProperty(target, prop) {
      callback({
        action: "delete",
        path: getPath(prop),
        stack: new Error().stack.split("\n")[1].trim()
      });
      return Reflect.deleteProperty(target, prop);
    },
  });
}
const unbuildProxy = (value) => {
  if (isObject(value)) {
    value = Array.isArray(value)? [...value] : {...value};

    // Do not use unguarded for in loops. BM67 Replaced with for of loop
    for (const key of Object.keys(value)) { value[key] = unbuildProxy(value[key]) }
  }
  return value;
}

var data = buildProxy({a: {b: true}});
data.a.b = {c: true};
data.a.b.c = data.a;
data = unbuildProxy(data);

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.