feat: add capability to identify membrane proxies#48
Conversation
What do you think about making it not show up in |
dgitin
left a comment
There was a problem hiding this comment.
Would making the property writable and available via set be too much ? I'm thinking this could be used for other scenarios when a simple true/false check may not be enough.
that breaks the object invariants if they decide to freeze the proxy. That's why we have to provide a consistent result for
do you have an actual use-case here? I will like to stick the MVP here. |
src/reactive-dev-formatter.ts
Outdated
| const names = getOwnPropertyNames(objectOrArray); | ||
| return ArrayConcat.call(names, getOwnPropertySymbols(objectOrArray)) | ||
| .reduce((seed: any, key: PropertyKey) => { | ||
| const names = getOwnKeysAndMagicProperty(objectOrArray, undefined); |
There was a problem hiding this comment.
just reusing a new shared lib function
src/reactive-handler.ts
Outdated
| const targetKeys = ArrayConcat.call(getOwnPropertyNames(originalTarget), getOwnPropertySymbols(originalTarget)); | ||
| const targetKeys = getOwnKeysAndMagicProperty(originalTarget, membrane.magicPropertyKey); | ||
| targetKeys.forEach((key: PropertyKey) => { | ||
| let descriptor = getOwnPropertyDescriptor(originalTarget, key) as PropertyDescriptor; |
There was a problem hiding this comment.
removing the casting... in favor of an if condition, this also help with the case were one key is not in the original target because it is the magic key
src/reactive-handler.ts
Outdated
| const { originalTarget } = this; | ||
| return ArrayConcat.call(getOwnPropertyNames(originalTarget), getOwnPropertySymbols(originalTarget)); | ||
| ownKeys(shadowTarget: ReactiveMembraneShadowTarget): PropertyKey[] { | ||
| return getOwnKeysAndMagicProperty(this.originalTarget, this.membrane.magicPropertyKey); |
There was a problem hiding this comment.
reusing shared lib func
src/reactive-handler.ts
Outdated
| // value is present. This eliminates getter and setter descriptors | ||
| if (hasOwnProperty.call(descriptor, 'writable') && !hasOwnProperty.call(descriptor, 'value')) { | ||
| const originalDescriptor = getOwnPropertyDescriptor(originalTarget, key) as PropertyDescriptor; | ||
| const originalDescriptor = getOwnPropertyDescriptor(originalTarget, key); |
There was a problem hiding this comment.
this change is needed to accommodate for the new fact that sometimes the key is not installed on the original target, but on the shadow for the magic key
src/reactive-membrane.ts
Outdated
| } | ||
| if (!isUndefined(magicPropertyKey)) { | ||
| // installing the magic property as configurable, non-writable and non-enumerable | ||
| ObjectDefineProperty(shadowTarget, magicPropertyKey, { configurable: true }); |
There was a problem hiding this comment.
configurable, non-writable is the optimal definition since it can still change if needed, and the object invariants will not apply
There was a problem hiding this comment.
When would we reuse the same shadow target and reset the property?
There was a problem hiding this comment.
we will never reuse a shadow target, is that what you're asking?
There was a problem hiding this comment.
Yes. I don't understand why the magicPropertyKey is set to configurable.
| getPrototypeOf, | ||
| create: ObjectCreate, | ||
| defineProperty: ObjectDefineProperty, | ||
| defineProperties: ObjectDefineProperties, |
There was a problem hiding this comment.
removing tons of stuff that were not used anywhere
src/shared.ts
Outdated
| export const unwrap = (replicaOrAny: any): any => proxyToValueMap.get(replicaOrAny) || replicaOrAny; | ||
|
|
||
| export function getOwnKeysAndMagicProperty(target: object, magicPropertyKey: PropertyKey | undefined): PropertyKey[] { | ||
| const keys = isUndefined(magicPropertyKey) || hasOwnProperty.call(target, magicPropertyKey) ? [] : [magicPropertyKey]; |
There was a problem hiding this comment.
this method follows @jdalton suggestion to avoid the creation of an extra array when doing concat. it also adds the magic key when present.
caridy
left a comment
There was a problem hiding this comment.
this is ready for another pass folks!
| .reduce((seed: any, key: PropertyKey) => { | ||
| const names = getOwnKeysAndMagicProperty(objectOrArray, undefined); | ||
| return names.reduce((seed: any, key: PropertyKey) => { | ||
| const item = objectOrArray[key]; |
|
I saw a code comment about freezing objects. Is there complications there? |
Yes, the problem is that when freezing the proxy, the engine will read all own keys, then loop them to get each individual descriptor (this list will contain the magic key), then it calls |
|
Bikeshedding: While I like the magic aspect of the |
Will projecting the property on a frozen object cause problems (errors) if the property is not installed on the object?
I'll throw in |
|
I like
The property is on the shadowTarget, if you freeze the proxy, it freezes the target and the shadowTarget, the target will not get the magic key, while the shadowTarget has it. |
src/reactive-handler.ts
Outdated
| if (!descriptor.configurable) { | ||
| descriptor = wrapDescriptor(membrane, descriptor, wrapValue); | ||
| let descriptor = getOwnPropertyDescriptor(originalTarget, key); | ||
| if (!isUndefined(descriptor)) { |
There was a problem hiding this comment.
unless this code allows someone destroying a property during the loop code, all the own keys will have a defined descriptor, right?
There was a problem hiding this comment.
that's the thing, getOwnKeysAndMagicProperty will add a the magical key to the list... which might or might not be in the originalTarget.
src/reactive-handler.ts
Outdated
| if (hasOwnProperty.call(descriptor, 'writable') && !hasOwnProperty.call(descriptor, 'value')) { | ||
| const originalDescriptor = getOwnPropertyDescriptor(originalTarget, key) as PropertyDescriptor; | ||
| const originalDescriptor = getOwnPropertyDescriptor(originalTarget, key); | ||
| if (!isUndefined(originalDescriptor) && hasOwnProperty.call(descriptor, 'writable') && !hasOwnProperty.call(descriptor, 'value')) { |
There was a problem hiding this comment.
| if (!isUndefined(originalDescriptor) && hasOwnProperty.call(descriptor, 'writable') && !hasOwnProperty.call(descriptor, 'value')) { | |
| if (originalDescriptor && hasOwnProperty.call(descriptor, 'writable') && !hasOwnProperty.call(descriptor, 'value')) { |
There was a problem hiding this comment.
all these libraries are using isUndefined helper for two reasons:
- it is supposed to be faster because it doesn't have to cast the value.
- It is easier to read and reasoning about what the condition is about.
src/reactive-handler.ts
Outdated
| // we must avoid installing the magic descriptor into the original target, | ||
| // because it will leak internal implementation details. This is tricky because | ||
| // Object.freeze() will attempt to do so. | ||
| if (magicPropertyKey !== key || !isUndefined(originalDescriptor)) { |
src/reactive-membrane.ts
Outdated
| } else if (isObject(value)) { | ||
| shadowTarget = {}; | ||
| } | ||
| if (!isUndefined(magicPropertyKey)) { |
There was a problem hiding this comment.
these !isUndefined checks are weird. For the property key I'd suggest creating a method for: isPropertyKey following the semantics of ES:
- If Type(argument) is String, return true.
- If Type(argument) is Symbol, return true.
- Return false.
There was a problem hiding this comment.
the isUndefined is weird, yes... ideally we can have isDefined but I don't think TS allows to do a good declaration of that, @pmdartus might know how... but in general, we use isUndefined everywhere... see my previous message.
In this case, it is because the magical property key, which is well defined in the type system, could be undefined or a valid property key.
There was a problem hiding this comment.
Here is how you would implement isDefined with typescript:
function isDefined<T>(value: T | undefined): value is T {
return value !== undefined;
}
src/read-only-handler.ts
Outdated
| let desc = getOwnPropertyDescriptor(originalTarget, key); | ||
| if (isUndefined(desc)) { | ||
| if (key === magicPropertyKey) { | ||
| // key is the magic key, the shadow target must have a descriptor for it. |
There was a problem hiding this comment.
| // key is the magic key, the shadow target must have a descriptor for it. | |
| // the shadow target must have a descriptor this key |
src/shared.ts
Outdated
|
|
||
| export const unwrap = (replicaOrAny: any): any => proxyToValueMap.get(replicaOrAny) || replicaOrAny; | ||
|
|
||
| export function getOwnKeysAndMagicProperty(target: object, magicPropertyKey: PropertyKey | undefined): PropertyKey[] { |
There was a problem hiding this comment.
This is missing tests in the shared.spec.ts file.
src/reactive-handler.ts
Outdated
| return key in originalTarget; | ||
| // since key is never going to be undefined, and magicPropertyKey might be undefined | ||
| // we can simply compare them as the second part of the condition. | ||
| return key in originalTarget || key === membrane.magicPropertyKey; |
There was a problem hiding this comment.
I don't understand the comment above and why the key check has on the right-hand side of the expression. Performance wire moving the identity check on the left-hand side is preferable.
src/reactive-membrane.ts
Outdated
| } else if (isObject(value)) { | ||
| shadowTarget = {}; | ||
| } | ||
| if (!isUndefined(magicPropertyKey)) { |
There was a problem hiding this comment.
Here is how you would implement isDefined with typescript:
function isDefined<T>(value: T | undefined): value is T {
return value !== undefined;
}
src/reactive-membrane.ts
Outdated
| } | ||
| if (!isUndefined(magicPropertyKey)) { | ||
| // installing the magic property as configurable, non-writable and non-enumerable | ||
| ObjectDefineProperty(shadowTarget, magicPropertyKey, { configurable: true }); |
There was a problem hiding this comment.
When would we reuse the same shadow target and reset the property?
ravijayaramappa
left a comment
There was a problem hiding this comment.
One other question:
- In reactive-handler: For the deleteProperty trap, it will always delete a property from the original target. If the magic property is defined by the user on proxy using defineProperty and they wish to delete it, the property will never be deleted.
src/reactive-dev-formatter.ts
Outdated
| const names = getOwnPropertyNames(objectOrArray); | ||
| return ArrayConcat.call(names, getOwnPropertySymbols(objectOrArray)) | ||
| .reduce((seed: any, key: PropertyKey) => { | ||
| const names = getOwnKeysAndMagicProperty(objectOrArray, undefined); |
There was a problem hiding this comment.
| const names = getOwnKeysAndMagicProperty(objectOrArray, undefined); | |
| const names = getOwnKeysAndMagicProperty(objectOrArray); |
There was a problem hiding this comment.
that will be a type error because the argument is expect. we can make it optional though.
src/reactive-handler.ts
Outdated
| descriptor.value = originalDescriptor.value; | ||
| } | ||
| ObjectDefineProperty(originalTarget, key, unwrapDescriptor(descriptor)); | ||
| // we must avoid installing the magic descriptor into the original target, |
There was a problem hiding this comment.
opinion: This is a bit complex to comprehend.
- The proxy does not allow a magic property to be defined on the original target.
- But it can be defined on the shadow target.
We are juggling quite a big to allow the magic property key to
- be an indicator of a proxy object
- be a defined property on the shadow target
- support the original target to have a property key same as magic property key
We would greatly simplify if the magic property had only one purpose, to identify a proxy.
| expect('foo' in o).toBe(false); | ||
| expect(wet.foo).toBe(undefined); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Add test cases for
- Defining a new property using Object.defineProperty on the wet object
- Delete the magic property key on the wet object
- Have magic property defined on dry object, define same property on wet object, delete the property on wet object. Recheck if property exists and the value.
There was a problem hiding this comment.
On top of this list, I would also add test to check the returned property descriptor of the magic propety on the wet object: { value: undefined, enumerable: false, configurable: false, writable: false }
There was a problem hiding this comment.
@pmdartus this is the test that you asked for, in fact surface a problem, thanks:
b2aec8c
@ravijayaramappa I don't understand your test... can you elaborate?
There was a problem hiding this comment.
Assuming that the magic property is discoverable, i was suggesting to add test cases for the corner cases.
const o = {};
const target = new ReactiveMembrane({
tagPropertyKey: '$$MagicKey$$',
});
const wet = target.getProxy(o);
Object.defineProperty(wet, '$$MagicKey$$', {
value: 'bar',
configurable: true
});
#1
expect('$$MagicKey$$' in wet).toBe(true);
expect(object.keys(wet)).toEqual(['$$MagicKey$$']);
#2
delete wet["$$MagicKey$$"]
expect('$$MagicKey$$' in wet).toBe(false); // what should happen here?
expect(object.keys(wet)).toEqual([]); There was a problem hiding this comment.
#3
const dry = {};
const target = new ReactiveMembrane({
tagPropertyKey: '$$MagicKey$$',
});
const wet = target.getProxy(dry);
Object.defineProperty(dry, '$$MagicKey$$', {
value: 'bar',
configurable: true
});
Object.defineProperty(wet, '$$MagicKey$$', {
value: 'baz',
configurable: true
});
expect('$$MagicKey$$' in wet).toBe(true);
expect(object.keys(wet)).toEqual(['$$MagicKey$$']);
expect(wet['$$MagicKey$$]).toBe('baz');
expect(dry['$$MagicKey$$]).toBe('baz'); // value set on wet propagated to dry
delete wet["$$MagicKey$$"]
expect('$$MagicKey$$' in wet).toBe(false);
expect(object.keys(wet)).toEqual([]);
expect(dry['$$MagicKey$$]).toBeUndefined();
expect('$$MagicKey$$' in dry).toBe(false);
expect(object.keys(dry)).toEqual([]);
expect(dry['$$MagicKey$$]).toBeUndefined();#4 would be the same test setup with the descriptor marked as configurable: false
0e71c5a to
5b7c31e
Compare
|
|
||
| export type ReactiveMembraneShadowTarget = object; | ||
|
|
||
| export function createShadowTarget(value: any): ReactiveMembraneShadowTarget { |
There was a problem hiding this comment.
this was only used by membrane, moving it there.
| }; | ||
| const defaultValueDistortion: ReactiveMembraneDistortionCallback = (value: any) => value; | ||
|
|
||
| function createShadowTarget(value: any): any { |
There was a problem hiding this comment.
moved from base since it is only used here
test/reactive-handler.spec.ts
Outdated
| }); | ||
| }); | ||
| }); | ||
| describe('with magic key property', () => { |
|
LGTM. The freezing issue is resolved it looks like too? |
Yes! |
|
👍 |
| // if the key is the membrane tag key, and is not in the original target, | ||
| // we produce a synthetic descriptor and install it on the shadow target | ||
| desc = { value: undefined, writable: false, configurable: false, enumerable: false }; | ||
| ObjectDefineProperty(shadowTarget, tagPropertyKey, desc); |
There was a problem hiding this comment.
It is safe to return the desc at this point. This one is pretty harmless and does not need to be wrapped.
Also, it is un-necessary to execute the if block in L147-150 on this desc.
… another perf optimization
Use Case
As a developer, I can add a mark to read-only and read/write proxies so I can identify those proxy objects without attempting to proxify them again.
API
This new configuration called
tagPropertyKeyis optional, and if provided, it is expected to be aPropertyKey(string or symbol).Semantics
When
tagPropertyKeyconfiguration is provided, any read-only and read/write proxy created by the membrane will "contain" an own property with that name. E.g.: