Skip to content

feat: add capability to identify membrane proxies#48

Merged
caridy merged 6 commits intomasterfrom
caridy/magic-property-key
Jun 16, 2020
Merged

feat: add capability to identify membrane proxies#48
caridy merged 6 commits intomasterfrom
caridy/magic-property-key

Conversation

@caridy
Copy link
Contributor

@caridy caridy commented May 21, 2020

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

const flag = Symbol();
const membrane = new ObservableMembrane({
    tagPropertyKey: flag,
});

This new configuration called tagPropertyKey is optional, and if provided, it is expected to be a PropertyKey (string or symbol).

Semantics

When tagPropertyKey configuration is provided, any read-only and read/write proxy created by the membrane will "contain" an own property with that name. E.g.:

flag in proxy; // yields true
Reflect.has(proxy, flag); // yields true
proxy[flag]; // yields undefined
Object.getOwnPropertyDescriptor(proxy, flag); // { value: undefined, writable: false, configurable: false, enumerable: false }

@jdalton
Copy link

jdalton commented May 21, 2020

Object.getOwnPropertyDescriptor(proxy, flag);

What do you think about making it not show up in Object.getOwnPropertyDescriptor() or Reflect.ownKeys() this so it only shows up in in and Reflect.has operations?

Copy link

@dgitin dgitin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@caridy
Copy link
Contributor Author

caridy commented May 22, 2020

@jdalton

What do you think about making it not show up in Object.getOwnPropertyDescriptor() or Reflect.ownKeys() this so it only shows up in in and Reflect.has operations?

that breaks the object invariants if they decide to freeze the proxy. That's why we have to provide a consistent result for has, getOwnPropertyDescriptor and ownKeys.

@dgitin

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.

do you have an actual use-case here? I will like to stick the MVP here.

const names = getOwnPropertyNames(objectOrArray);
return ArrayConcat.call(names, getOwnPropertySymbols(objectOrArray))
.reduce((seed: any, key: PropertyKey) => {
const names = getOwnKeysAndMagicProperty(objectOrArray, undefined);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just reusing a new shared lib function

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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

const { originalTarget } = this;
return ArrayConcat.call(getOwnPropertyNames(originalTarget), getOwnPropertySymbols(originalTarget));
ownKeys(shadowTarget: ReactiveMembraneShadowTarget): PropertyKey[] {
return getOwnKeysAndMagicProperty(this.originalTarget, this.membrane.magicPropertyKey);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reusing shared lib func

// 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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

}
if (!isUndefined(magicPropertyKey)) {
// installing the magic property as configurable, non-writable and non-enumerable
ObjectDefineProperty(shadowTarget, magicPropertyKey, { configurable: true });
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configurable, non-writable is the optimal definition since it can still change if needed, and the object invariants will not apply

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would we reuse the same shadow target and reset the property?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we will never reuse a shadow target, is that what you're asking?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I don't understand why the magicPropertyKey is set to configurable.

getPrototypeOf,
create: ObjectCreate,
defineProperty: ObjectDefineProperty,
defineProperties: ObjectDefineProperties,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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];
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method follows @jdalton suggestion to avoid the creation of an extra array when doing concat. it also adds the magic key when present.

Copy link
Contributor Author

@caridy caridy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could add ArrayReduce

@jdalton
Copy link

jdalton commented May 22, 2020

I saw a code comment about freezing objects. Is there complications there?

@caridy
Copy link
Contributor Author

caridy commented May 23, 2020

@jdalton

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 defineProperty, which we normally install on the actual target, this will leak the magic key as a descriptor in the real target. I added a condition to avoid leaking that particular magic key.

@pmdartus
Copy link
Contributor

Bikeshedding: While I like the magic aspect of the magicPropertyKey, what do you think about naming it something like membaneStamp or membraneKey?

@jdalton
Copy link

jdalton commented May 26, 2020

@caridy

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 defineProperty, which we normally install on the actual target, this will leak the magic key as a descriptor in the real target. I added a condition to avoid leaking that particular magic key.

Will projecting the property on a frozen object cause problems (errors) if the property is not installed on the object?

@pmdartus

Bikeshedding: While I like the magic aspect of the magicPropertyKey, what do you think about naming it something like membraneStamp or membraneKey?

I'll throw in membraneBrand or membraneTag.

@caridy
Copy link
Contributor Author

caridy commented May 26, 2020

I like membraneTag

@jdalton

Will projecting the property on a frozen object cause problems (errors) if the property is not installed on the object?

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.

if (!descriptor.configurable) {
descriptor = wrapDescriptor(membrane, descriptor, wrapValue);
let descriptor = getOwnPropertyDescriptor(originalTarget, key);
if (!isUndefined(descriptor)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unless this code allows someone destroying a property during the loop code, all the own keys will have a defined descriptor, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's the thing, getOwnKeysAndMagicProperty will add a the magical key to the list... which might or might not be in the originalTarget.

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')) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!isUndefined(originalDescriptor) && hasOwnProperty.call(descriptor, 'writable') && !hasOwnProperty.call(descriptor, 'value')) {
if (originalDescriptor && hasOwnProperty.call(descriptor, 'writable') && !hasOwnProperty.call(descriptor, 'value')) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

// 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)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here for !isUndefined

} else if (isObject(value)) {
shadowTarget = {};
}
if (!isUndefined(magicPropertyKey)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these !isUndefined checks are weird. For the property key I'd suggest creating a method for: isPropertyKey following the semantics of ES:

  1. If Type(argument) is String, return true.
  2. If Type(argument) is Symbol, return true.
  3. Return false.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is how you would implement isDefined with typescript:

function isDefined<T>(value: T | undefined): value is T {
    return value !== undefined;
}

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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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[] {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing tests in the shared.spec.ts file.

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

} else if (isObject(value)) {
shadowTarget = {};
}
if (!isUndefined(magicPropertyKey)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is how you would implement isDefined with typescript:

function isDefined<T>(value: T | undefined): value is T {
    return value !== undefined;
}

}
if (!isUndefined(magicPropertyKey)) {
// installing the magic property as configurable, non-writable and non-enumerable
ObjectDefineProperty(shadowTarget, magicPropertyKey, { configurable: true });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would we reuse the same shadow target and reset the property?

@ravijayaramappa ravijayaramappa changed the title feature: add capability to include magic property key to identify membrane proxies feature: add capability to identify membrane proxies Jun 2, 2020
@ravijayaramappa ravijayaramappa changed the title feature: add capability to identify membrane proxies feat: add capability to identify membrane proxies Jun 2, 2020
Copy link
Contributor

@ravijayaramappa ravijayaramappa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One other question:

  1. 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.

const names = getOwnPropertyNames(objectOrArray);
return ArrayConcat.call(names, getOwnPropertySymbols(objectOrArray))
.reduce((seed: any, key: PropertyKey) => {
const names = getOwnKeysAndMagicProperty(objectOrArray, undefined);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const names = getOwnKeysAndMagicProperty(objectOrArray, undefined);
const names = getOwnKeysAndMagicProperty(objectOrArray);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that will be a type error because the argument is expect. we can make it optional though.

descriptor.value = originalDescriptor.value;
}
ObjectDefineProperty(originalTarget, key, unwrapDescriptor(descriptor));
// we must avoid installing the magic descriptor into the original target,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opinion: This is a bit complex to comprehend.

  1. The proxy does not allow a magic property to be defined on the original target.
  2. But it can be defined on the shadow target.

We are juggling quite a big to allow the magic property key to

  1. be an indicator of a proxy object
  2. be a defined property on the shadow target
  3. 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);
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add test cases for

  1. Defining a new property using Object.defineProperty on the wet object
  2. Delete the magic property key on the wet object
  3. 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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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([]); 

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#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

@caridy caridy force-pushed the caridy/magic-property-key branch from 0e71c5a to 5b7c31e Compare June 12, 2020 19:25

export type ReactiveMembraneShadowTarget = object;

export function createShadowTarget(value: any): ReactiveMembraneShadowTarget {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was only used by membrane, moving it there.

};
const defaultValueDistortion: ReactiveMembraneDistortionCallback = (value: any) => value;

function createShadowTarget(value: any): any {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved from base since it is only used here

});
});
});
describe('with magic key property', () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

magic key -> tag key property

@jdalton
Copy link

jdalton commented Jun 15, 2020

LGTM. The freezing issue is resolved it looks like too?

@caridy
Copy link
Contributor Author

caridy commented Jun 15, 2020

LGTM. The freezing issue is resolved it looks like too?

Yes!

@jdalton
Copy link

jdalton commented Jun 15, 2020

👍

// 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);
Copy link
Contributor

@ravijayaramappa ravijayaramappa Jun 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@caridy caridy merged commit 3c0ad4d into master Jun 16, 2020
@caridy caridy deleted the caridy/magic-property-key branch June 16, 2020 18:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants