Improve compatibility of tracked properties and native proxies
- Dominant language
- TypeScript
- Stars
- 22.6k
- Forks
- 4.2k
- Avg merge
- 3d 12h
- Merged PRs (30d)
- 15
Description
### Context
The built-in [Reflect](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect) object gives developers the ability to mask or override the `this` of an object's [property](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/get) [accessors](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/set) by specifying an alternative _thisArg_ at call time. This is useful for applying the behavior of an existing object in a modified context, without cloning or altering the underlying object. (And probably for other use-cases as well, but this is the one I'm interested in.)
Consider the following example:
```javascript
class Foo {
constructor(num) {
this.num = +num;
}
get doubleNum() {
return this.num * 2;
}
}
```
I'm going to create a new instance of this class and send it off somewhere to be used. I'd like to make all of the advanced wizardry of `Foo#doubleNum` available downstream, but I don't want any pesky consumers directly modifying my object. (Yes, like a change set. I'm talking about change sets.) What I can do is pass down a native **Proxy** object instead of the "raw" object.
Something like:
```javascript
function makeProxy(obj) {
const buffer = new Map();
return new Proxy(obj, {
get(target, key) {
if (key === 'num' && buffer.has(key)) {
return buffer.get(key);
}
return target[key];
},
set(target, key, value) {
if (key === 'num') {
buffer.set(key, value);
return true;
}
/*
* If we were to make the other properties of our underlying
* object writable...
*
target[key] = value;
return true;
*/
console.error('oopsie! this object is (mostly) read-only');
return false;
}
});
}
```
So far so good. Downstream consumers can read and write the `num` property without modifying `myFoo`, and everything else is read-only. And with a few more lines of code, I can easily add the ability to apply the buffered changes at a time of one's choosing (e.g. after a user clicks the "Save" button).
But there's one problem:
```javascript
const
myFoo = new Foo(10),
myFooProxy = makeProxy(myFoo);
myFooProxy.num = 500; // => 500
myFooProxy.doubleNum; // => 20 (???)
myFoo.num; // => 10
myFoo.doubleNum; // => 20
```
`myFooProxy.doubleNum` is always 20—regardless of the value of `myFooProxy.num`—because the `this` of the getter method is the underlying `myFoo` instance of Foo—as you'd expect—and `myFoo` has no access to the change buffer. What we need to do is override the `this` of `#doubleNum` at call time so it can retrieve the buffered value of `num` from the Proxy object.
This is where **Reflect** comes in:
```javascript
function makeProxy(obj) {
const buffer = new Map();
return new Proxy(obj, {
get(target, key, receiver) { // <-- HERE
if (key === 'num' && buffer.has(key)) {
return buffer.get(key);
}
return Reflect.get(target, key, receiver); // <-- HERE
},
set(target, key, value, receiver) { // <-- HERE
if (key === 'num') {
buffer.set(key, value);
return true;
}
/*
* If we were to make the other properties of our underlying
* object writable...
*
Reflect.set(target, key, value, receiver); // <-- HERE
return true;
*/
console.error('oopsie! this object is (mostly) read-only');
return false;
}
});
}
```
We take the additional _receiver_ argument of the Proxy handler's `get` and `set` traps and pass it along via `Reflect.get` and `Reflect.set`. This overrides the `this` of our property accessors and lets `myFooProxy.doubleNum` get the buffered value of `num` from the Proxy object.
Now everything works how we want:
```javascript
const
myFoo = new Foo(10),
myFooProxy = makeProxy(myFoo);
myFooProxy.num = 500; // => 500
myFooProxy.doubleNum; // => 1000 (!!!)
myFoo.num; // => 10
myFoo.doubleNum; // => 20
```
### Problems
So why am I submitting this long, rambling issue report about (_checks notes_) JavaScript built-ins to the Ember.js repo? Simply put, the implementation of tracked properties in Ember is incompatible with this pattern. As soon as you add `@tracked num;` to the top of Foo's class definition, you get some spooky behavior.
I've identified the following "areas of concern":
1. **The value of a tracked property—when assigned via an underlying object's constructor or some other unmasked method—is inaccessible via reflection.** The `this` of e.g. a constructor is always the object being constructed, so the given value will be stored in the underlying object's slot of the tracked property's `values` WeakMap. When you try to retrieve it with `Reflect.get(underlyingObject, key, proxyObject)`, it looks in the Proxy object's slot instead.
1. **The value of a tracked property—when assigned via reflection—does not "punch through" to the underlying object.** This is the reverse of the above issue, i.e. when a tracked property is mutated with `Reflect.set(underlyingObject, key, value, proxyObject)`. The Proxy object is implicitly buffering tracked properties... whether you're aware of it or not!
_N.B. To be fair, this is a weird one, because you probably wouldn't want to mask the `this` of **most** setter methods. But if a developer's mental model of tracked properties is more "data descriptors" than "accessor descriptors", this turns into one heck of a pitfall._
1. **Buffered tracked properties are not tracked.** This one is "obvious", but worth mentioning as a footgun and to draw contrast with the next point. The solution is to use a tracking-aware buffer object, e.g. a TrackedMap or ember-changeset.
1. **Autotracking can't "pierce the veil" for un-buffered (passed-through) tracked properties.** If an underlying tracked property is mutated by some other means, the Proxy handler's `get` trap does not fire.
_Open Question: Perhaps because the tracked property accessors pass the "wrong" `this` to `tagForProperty()`?_
### Workarounds
The solution I've employed thus far is to check for tracked properties in my Proxy handlers and omit the _receiver_ argument to `Reflect.get` and `Reflect.set` (or simply use `Ember.get` and `Ember.set` instead). Unfortunately, I don't know of any particularly good way to determine at runtime whether or not a property is tracked, so I'm clumsily searching up the prototype chain to find the descriptor and inspecting `descriptor.get.toString()`.
### Solutions
I don't think I understand the problem or the implementation of tracked properties well enough to know what the "right" solution is, but I have a few ideas of what would be helpful for my use-case:
* **We can provide an `isTrackedProperty(object, propertyName)` public API** (assuming one doesn't already exist). This would make userspace solutions like mine somewhat more manageable.
* **We can provide Ember-friendly alternatives to `Reflect.get` and `Reflect.set` that Do The Right ThingTM, whatever that ends up being, when they encounter tracked properties.** Alternatively, updated versions of `Ember.get` and `Ember.set` that take an optional _thisArg_ argument (and otherwise work the same) would be lovely.
* **We can enhance the implementation of tracked properties to be native Proxy-aware** and/or introduce generic hook(s) that would allow an object to send up another object to be used as the `this` for tracked property accessors.
Contributor guide
Assessment
This issue has not been assessed yet.