Barney
Barney

Reputation: 16456

Why does Proxy break this-binding?

I'm trying to proxy a set of object such that I can pass them off to third party code and temporarily nullify mutation methods and setters, and then revoke the proxy handler traps to reinstate normal behaviour. What I've discovered is that proxies are inherently hostile to this-dependent code.

I'm intrigued as to how & why Javascript Proxies break this binding for their proxied targets. In the following example I have a simple class which ingests a value on construction, stores it as a private field, and returns it on property access. Observe that:

  1. Attempting to access the property through a proxy without a handler throws an error
  2. Explicitly forwarding a handler get trap through Reflect.get reinstates normal behaviour

class Thing {
  #value

  constructor(value){
    this.#value = value
  }
  
  get value(){
    return this.#value
  }
}

// Default behaviour
const thing1 = new Thing('foo')

attempt(() => thing1.value)

// No-op proxy breaks contextual access behaviour
const proxy1 = new Proxy(thing1, {})

attempt(() => proxy1.value)

// Reinstated by explicitly forwarding handler get call to Reflect
const proxy2 = new Proxy(thing1, {get: (target, key) =>
  Reflect.get(target, key)
})

attempt(() => proxy2.value)

function attempt(fn){
  try {
    console.log(fn())
  }
  catch(e){
    console.error(e)
  }
}

This offers a workaround for getter access, but I don't understand why the problem arises or why the extra code fixes it. The same problem of context clash in unhandled proxy queries is more vexing when it comes to methods. In the following code, the value property becomes a method rather than a getter. In this case:

  1. Default behaviour is still broken
  2. Reflect.get no longer works
  3. I can explicitly bind this in the get trap
  4. But this isn't a reinstatement of expected behaviour

class Thing {
  #value

  constructor(value){
    this.#value = value
  }
  
  value(){
    return this.#value
  }
}

// Default behaviour
const thing1 = new Thing('foo')

attempt(() => thing1.value())

// No-op proxy breaks contextual access behaviour
const proxy1 = new Proxy(thing1, {})

attempt(() => proxy1.value())

// Forwarding handler get trap to Reflect doesn't work
const proxy2 = new Proxy(thing1, {get: (target, key) =>
  Reflect.get(target, key)
})

attempt(() => proxy2.value())

// Explicitly binding the returned method *does* work
const proxy3 = new Proxy(thing1, {get: (target, key) =>
  target[key].bind(target)
})

attempt(() => proxy3.value())

// But this goes beyond reinstating normal behaviour
var {value} = thing1

attempt(() => value())

var {value} = proxy3

attempt(() => value())

function attempt(fn){
  try {
    console.log(fn())
  }
  catch(e){
    console.error(e)
  }
}

Upvotes: 3

Views: 1585

Answers (1)

Randy Casburn
Randy Casburn

Reputation: 14165

TDLR;

  1. Private access requires the context of operation to be set to the object that created the private member (Proxy solution provided)
  2. For the use case and contrived code you supplied, a Proxy is not required as simple inheritance can be used to accomplish the goal (solution at very bottom)

First Example

The no-op Proxy in the first example is not broken. The get() method is still invoked via the Proxy object (even no-op) and not the thing object. So the private member is not accessible via the proxy with proxy1.value. Your fix in the first example, using reflection, is the common way of accessing those members in almost every language with access restrictions (some require inflection). Historically, before Reflect.get() was available, this was done using the function object's .apply() method. So the use of Reflect.get() makes sense for the very same reason.

Bottom Line:

So you must take some action to set the context to the object that created the private member, otherwise you will not gain access to it.

Second Example

The call to Reflect.get() doesn't work in the second example because of shifting getter syntax from get value() to value(). Now that a function is called to retrieve value it must be bound to the correct object. Simple reflection is not sufficient. To make Reflect.get() work here you must bind the getter function to the target.

Using function's .bind() method is the other traditional way of controlling the context of operation. From the docs:

The bind() method creates a new function that, when called, has its this keyword set to the provided value...

Reflect.get(target, key).bind(target)

Which is exactly the same as your use of .bind() in this:

target[key].bind(target)

The static Reflect.get() method works like getting a property from an object (target[propertyKey]) as a function.

Bottom Line

In both cases (Reflect.get(), .bind()), the context is shifted to the object that created the private member. This is necessary in many use cases and is not isolated to Proxy.

Working Solution with Proxy

class Thing {
  #value
  constructor(value) { this.#value = value }
  value() { return this.#value }
  get value() { return this.#value; }
  set value(v) { this.#value = v; }
  someMethod() { return 'Cannot get here when proxied.'}
}

const thing = new Thing('foo')
const revokeMe = Proxy.revocable(thing, {
  get: (target, key) => {
    if (key === 'value') {
      return () => 'value is undefined (blocked by proxy)'
    }
    if(key === 'someMethod') {
      return () => `cannot invoke ${key}. (blocked by proxy)`;
    }
    return Reflect.get(target, key).bind(target);
  },
  set: (target, key, value) => {
    if (key === 'value') {
      console.log(`cannot set ${key} property. (blocked by proxy)`);
    }
    return Reflect.set(target, key, value);
  }
});
const proxy = revokeMe.proxy;
console.log(proxy.value());
proxy.value = 'test';
console.log(proxy.value());
console.log(proxy.someMethod());
revokeMe.revoke();
try {
  proxy.value();
} catch (err) {
  console.log('proxy has been revoked');
}
thing.value = 'new value';
console.log(thing.value);
console.log(thing.someMethod());

Working Solution Using Simple Inheritance

Focusing on this problem statement: "temporarily nullify mutation methods and setters, and then [...] reinstate normal behaviour."

The solution doesn't require a Proxy at all given the code you provided. Simply set the prototype of the object in question and override the properties/methods as necessary.

class Thing {
  #value
  constructor(value){ this.#value = value + ' cannot get here'}
  value(){ return this.#value + ' not gonna happen'}
  get value(){ return this.#value + ' not gonna happen'}
  set value(v) { this.#value = value;};
  toBeDisabled() { return 'will not show';}
}

class Overrides {
  constructor(value) {
    this.value = value + ' (set in Overrides)';
  }
  get value() {return 'value is undefined (set in Overrides)';}
  set value(v) {}
  toBeDisabled(){
    return 'NoOp (in Overrides)';
  }
}

let thing = new Thing('foo');
thing.__proto__ = new Overrides(thing.value);

thing.value = 'new value';
console.log(thing.value);
console.log(thing.toBeDisabled())
thing.__proto__ = {}
thing.value = 'now value will set; proxy is disabled;';
console.log(thing.value);

Upvotes: 4

Related Questions