observable-membrane
Version:
A Javascript Membrane implementation using Proxies to observe mutation on an object graph
415 lines (409 loc) • 16.3 kB
JavaScript
/**
* Copyright (C) 2017 salesforce.com, inc.
*/
const { isArray } = Array;
const { prototype: ObjectDotPrototype, getPrototypeOf, create: ObjectCreate, defineProperty: ObjectDefineProperty, isExtensible, getOwnPropertyDescriptor, getOwnPropertyNames, getOwnPropertySymbols, preventExtensions, hasOwnProperty, } = Object;
const { push: ArrayPush, concat: ArrayConcat } = Array.prototype;
function isUndefined(obj) {
return obj === undefined;
}
function isFunction(obj) {
return typeof obj === 'function';
}
const proxyToValueMap = new WeakMap();
function registerProxy(proxy, value) {
proxyToValueMap.set(proxy, value);
}
const unwrap = (replicaOrAny) => proxyToValueMap.get(replicaOrAny) || replicaOrAny;
class BaseProxyHandler {
constructor(membrane, value) {
this.originalTarget = value;
this.membrane = membrane;
}
// Shared utility methods
wrapDescriptor(descriptor) {
if (hasOwnProperty.call(descriptor, 'value')) {
descriptor.value = this.wrapValue(descriptor.value);
}
else {
const { set: originalSet, get: originalGet } = descriptor;
if (!isUndefined(originalGet)) {
descriptor.get = this.wrapGetter(originalGet);
}
if (!isUndefined(originalSet)) {
descriptor.set = this.wrapSetter(originalSet);
}
}
return descriptor;
}
copyDescriptorIntoShadowTarget(shadowTarget, key) {
const { originalTarget } = this;
// Note: a property might get defined multiple times in the shadowTarget
// but it will always be compatible with the previous descriptor
// to preserve the object invariants, which makes these lines safe.
const originalDescriptor = getOwnPropertyDescriptor(originalTarget, key);
// TODO: it should be impossible for the originalDescriptor to ever be undefined, this `if` can be removed
/* istanbul ignore else */
if (!isUndefined(originalDescriptor)) {
const wrappedDesc = this.wrapDescriptor(originalDescriptor);
ObjectDefineProperty(shadowTarget, key, wrappedDesc);
}
}
lockShadowTarget(shadowTarget) {
const { originalTarget } = this;
const targetKeys = ArrayConcat.call(getOwnPropertyNames(originalTarget), getOwnPropertySymbols(originalTarget));
targetKeys.forEach((key) => {
this.copyDescriptorIntoShadowTarget(shadowTarget, key);
});
const { membrane: { tagPropertyKey }, } = this;
if (!isUndefined(tagPropertyKey) && !hasOwnProperty.call(shadowTarget, tagPropertyKey)) {
ObjectDefineProperty(shadowTarget, tagPropertyKey, ObjectCreate(null));
}
preventExtensions(shadowTarget);
}
// Shared Traps
// TODO: apply() is never called
/* istanbul ignore next */
apply(shadowTarget, thisArg, argArray) {
/* No op */
}
// TODO: construct() is never called
/* istanbul ignore next */
construct(shadowTarget, argArray, newTarget) {
/* No op */
}
get(shadowTarget, key) {
const { originalTarget, membrane: { valueObserved }, } = this;
const value = originalTarget[key];
valueObserved(originalTarget, key);
return this.wrapValue(value);
}
has(shadowTarget, key) {
const { originalTarget, membrane: { tagPropertyKey, valueObserved }, } = this;
valueObserved(originalTarget, key);
// since key is never going to be undefined, and tagPropertyKey might be undefined
// we can simply compare them as the second part of the condition.
return key in originalTarget || key === tagPropertyKey;
}
ownKeys(shadowTarget) {
const { originalTarget, membrane: { tagPropertyKey }, } = this;
// if the membrane tag key exists and it is not in the original target, we add it to the keys.
const keys = isUndefined(tagPropertyKey) || hasOwnProperty.call(originalTarget, tagPropertyKey)
? []
: [tagPropertyKey];
// small perf optimization using push instead of concat to avoid creating an extra array
ArrayPush.apply(keys, getOwnPropertyNames(originalTarget));
ArrayPush.apply(keys, getOwnPropertySymbols(originalTarget));
return keys;
}
isExtensible(shadowTarget) {
const { originalTarget } = this;
// optimization to avoid attempting to lock down the shadowTarget multiple times
if (!isExtensible(shadowTarget)) {
return false; // was already locked down
}
if (!isExtensible(originalTarget)) {
this.lockShadowTarget(shadowTarget);
return false;
}
return true;
}
getPrototypeOf(shadowTarget) {
const { originalTarget } = this;
return getPrototypeOf(originalTarget);
}
getOwnPropertyDescriptor(shadowTarget, key) {
const { originalTarget, membrane: { valueObserved, tagPropertyKey }, } = this;
// keys looked up via getOwnPropertyDescriptor need to be reactive
valueObserved(originalTarget, key);
let desc = getOwnPropertyDescriptor(originalTarget, key);
if (isUndefined(desc)) {
if (key !== tagPropertyKey) {
return undefined;
}
// 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);
return desc;
}
if (desc.configurable === false) {
// updating the descriptor to non-configurable on the shadow
this.copyDescriptorIntoShadowTarget(shadowTarget, key);
}
// Note: by accessing the descriptor, the key is marked as observed
// but access to the value, setter or getter (if available) cannot observe
// mutations, just like regular methods, in which case we just do nothing.
return this.wrapDescriptor(desc);
}
}
const getterMap$1 = new WeakMap();
const setterMap$1 = new WeakMap();
const reverseGetterMap = new WeakMap();
const reverseSetterMap = new WeakMap();
class ReactiveProxyHandler extends BaseProxyHandler {
wrapValue(value) {
return this.membrane.getProxy(value);
}
wrapGetter(originalGet) {
const wrappedGetter = getterMap$1.get(originalGet);
if (!isUndefined(wrappedGetter)) {
return wrappedGetter;
}
const handler = this;
const get = function () {
// invoking the original getter with the original target
return handler.wrapValue(originalGet.call(unwrap(this)));
};
getterMap$1.set(originalGet, get);
reverseGetterMap.set(get, originalGet);
return get;
}
wrapSetter(originalSet) {
const wrappedSetter = setterMap$1.get(originalSet);
if (!isUndefined(wrappedSetter)) {
return wrappedSetter;
}
const set = function (v) {
// invoking the original setter with the original target
originalSet.call(unwrap(this), unwrap(v));
};
setterMap$1.set(originalSet, set);
reverseSetterMap.set(set, originalSet);
return set;
}
unwrapDescriptor(descriptor) {
if (hasOwnProperty.call(descriptor, 'value')) {
// dealing with a data descriptor
descriptor.value = unwrap(descriptor.value);
}
else {
const { set, get } = descriptor;
if (!isUndefined(get)) {
descriptor.get = this.unwrapGetter(get);
}
if (!isUndefined(set)) {
descriptor.set = this.unwrapSetter(set);
}
}
return descriptor;
}
unwrapGetter(redGet) {
const reverseGetter = reverseGetterMap.get(redGet);
if (!isUndefined(reverseGetter)) {
return reverseGetter;
}
const handler = this;
const get = function () {
// invoking the red getter with the proxy of this
return unwrap(redGet.call(handler.wrapValue(this)));
};
getterMap$1.set(get, redGet);
reverseGetterMap.set(redGet, get);
return get;
}
unwrapSetter(redSet) {
const reverseSetter = reverseSetterMap.get(redSet);
if (!isUndefined(reverseSetter)) {
return reverseSetter;
}
const handler = this;
const set = function (v) {
// invoking the red setter with the proxy of this
redSet.call(handler.wrapValue(this), handler.wrapValue(v));
};
setterMap$1.set(set, redSet);
reverseSetterMap.set(redSet, set);
return set;
}
set(shadowTarget, key, value) {
const { originalTarget, membrane: { valueMutated }, } = this;
const oldValue = originalTarget[key];
if (oldValue !== value) {
originalTarget[key] = value;
valueMutated(originalTarget, key);
}
else if (key === 'length' && isArray(originalTarget)) {
// fix for issue #236: push will add the new index, and by the time length
// is updated, the internal length is already equal to the new length value
// therefore, the oldValue is equal to the value. This is the forking logic
// to support this use case.
valueMutated(originalTarget, key);
}
return true;
}
deleteProperty(shadowTarget, key) {
const { originalTarget, membrane: { valueMutated }, } = this;
delete originalTarget[key];
valueMutated(originalTarget, key);
return true;
}
setPrototypeOf(shadowTarget, prototype) {
}
preventExtensions(shadowTarget) {
if (isExtensible(shadowTarget)) {
const { originalTarget } = this;
preventExtensions(originalTarget);
// if the originalTarget is a proxy itself, it might reject
// the preventExtension call, in which case we should not attempt to lock down
// the shadow target.
// TODO: It should not actually be possible to reach this `if` statement.
// If a proxy rejects extensions, then calling preventExtensions will throw an error:
// https://codepen.io/nolanlawson-the-selector/pen/QWMOjbY
/* istanbul ignore if */
if (isExtensible(originalTarget)) {
return false;
}
this.lockShadowTarget(shadowTarget);
}
return true;
}
defineProperty(shadowTarget, key, descriptor) {
const { originalTarget, membrane: { valueMutated, tagPropertyKey }, } = this;
if (key === tagPropertyKey && !hasOwnProperty.call(originalTarget, key)) {
// To avoid leaking the membrane tag property into the original target, we must
// be sure that the original target doesn't have yet.
// NOTE: we do not return false here because Object.freeze and equivalent operations
// will attempt to set the descriptor to the same value, and expect no to throw. This
// is an small compromise for the sake of not having to diff the descriptors.
return true;
}
ObjectDefineProperty(originalTarget, key, this.unwrapDescriptor(descriptor));
// intentionally testing if false since it could be undefined as well
if (descriptor.configurable === false) {
this.copyDescriptorIntoShadowTarget(shadowTarget, key);
}
valueMutated(originalTarget, key);
return true;
}
}
const getterMap = new WeakMap();
const setterMap = new WeakMap();
class ReadOnlyHandler extends BaseProxyHandler {
wrapValue(value) {
return this.membrane.getReadOnlyProxy(value);
}
wrapGetter(originalGet) {
const wrappedGetter = getterMap.get(originalGet);
if (!isUndefined(wrappedGetter)) {
return wrappedGetter;
}
const handler = this;
const get = function () {
// invoking the original getter with the original target
return handler.wrapValue(originalGet.call(unwrap(this)));
};
getterMap.set(originalGet, get);
return get;
}
wrapSetter(originalSet) {
const wrappedSetter = setterMap.get(originalSet);
if (!isUndefined(wrappedSetter)) {
return wrappedSetter;
}
const set = function (v) {
};
setterMap.set(originalSet, set);
return set;
}
set(shadowTarget, key, value) {
/* istanbul ignore next */
return false;
}
deleteProperty(shadowTarget, key) {
/* istanbul ignore next */
return false;
}
setPrototypeOf(shadowTarget, prototype) {
}
preventExtensions(shadowTarget) {
/* istanbul ignore next */
return false;
}
defineProperty(shadowTarget, key, descriptor) {
/* istanbul ignore next */
return false;
}
}
function defaultValueIsObservable(value) {
// intentionally checking for null
if (value === null) {
return false;
}
// treat all non-object types, including undefined, as non-observable values
if (typeof value !== 'object') {
return false;
}
if (isArray(value)) {
return true;
}
const proto = getPrototypeOf(value);
return proto === ObjectDotPrototype || proto === null || getPrototypeOf(proto) === null;
}
const defaultValueObserved = (obj, key) => {
/* do nothing */
};
const defaultValueMutated = (obj, key) => {
/* do nothing */
};
function createShadowTarget(value) {
return isArray(value) ? [] : {};
}
class ObservableMembrane {
constructor(options = {}) {
this.readOnlyObjectGraph = new WeakMap();
this.reactiveObjectGraph = new WeakMap();
const { valueMutated, valueObserved, valueIsObservable, tagPropertyKey } = options;
this.valueMutated = isFunction(valueMutated) ? valueMutated : defaultValueMutated;
this.valueObserved = isFunction(valueObserved) ? valueObserved : defaultValueObserved;
this.valueIsObservable = isFunction(valueIsObservable)
? valueIsObservable
: defaultValueIsObservable;
this.tagPropertyKey = tagPropertyKey;
}
getProxy(value) {
const unwrappedValue = unwrap(value);
if (this.valueIsObservable(unwrappedValue)) {
// When trying to extract the writable version of a readonly we return the readonly.
if (this.readOnlyObjectGraph.get(unwrappedValue) === value) {
return value;
}
return this.getReactiveHandler(unwrappedValue);
}
return unwrappedValue;
}
getReadOnlyProxy(value) {
value = unwrap(value);
if (this.valueIsObservable(value)) {
return this.getReadOnlyHandler(value);
}
return value;
}
unwrapProxy(p) {
return unwrap(p);
}
getReactiveHandler(value) {
let proxy = this.reactiveObjectGraph.get(value);
if (isUndefined(proxy)) {
// caching the proxy after the first time it is accessed
const handler = new ReactiveProxyHandler(this, value);
proxy = new Proxy(createShadowTarget(value), handler);
registerProxy(proxy, value);
this.reactiveObjectGraph.set(value, proxy);
}
return proxy;
}
getReadOnlyHandler(value) {
let proxy = this.readOnlyObjectGraph.get(value);
if (isUndefined(proxy)) {
// caching the proxy after the first time it is accessed
const handler = new ReadOnlyHandler(this, value);
proxy = new Proxy(createShadowTarget(value), handler);
registerProxy(proxy, value);
this.readOnlyObjectGraph.set(value, proxy);
}
return proxy;
}
}
export { ObservableMembrane };
/** version: 2.0.0 */