From b71b57dd4122582bd315f7e99caf5edf082bad01 Mon Sep 17 00:00:00 2001 From: cesarParra Date: Thu, 21 Nov 2024 07:08:51 -0400 Subject: [PATCH] Updated example that tracks a complex object --- .../lwc/demoSignals/shopping-cart.js | 9 +- .../lwc/checkoutButton/checkoutButton.js | 10 +- force-app/lwc/signals/core.js | 7 +- .../observable-membrane/base-handler.js | 118 +++++++++++++- .../lwc/signals/observable-membrane/main.js | 3 + .../observable-membrane.js | 9 +- .../reactive-dev-formatter.js | 1 + .../observable-membrane/reactive-handler.js | 97 ++++++++++- .../observable-membrane/read-only-handler.js | 24 ++- src/lwc/signals/__tests__/computed.test.ts | 5 +- src/lwc/signals/core.ts | 8 +- .../observable-membrane/base-handler.ts | 154 +++++++++++++++++- src/lwc/signals/observable-membrane/main.ts | 3 + .../observable-membrane.ts | 11 +- .../reactive-dev-formatter.ts | 4 +- .../observable-membrane/reactive-handler.ts | 114 ++++++++++++- .../observable-membrane/read-only-handler.ts | 112 ++++++++----- src/lwc/signals/observable-membrane/shared.ts | 1 - 18 files changed, 609 insertions(+), 81 deletions(-) diff --git a/examples/demo-signals/lwc/demoSignals/shopping-cart.js b/examples/demo-signals/lwc/demoSignals/shopping-cart.js index eeeb16f..65a86c1 100644 --- a/examples/demo-signals/lwc/demoSignals/shopping-cart.js +++ b/examples/demo-signals/lwc/demoSignals/shopping-cart.js @@ -19,14 +19,17 @@ import updateShoppingCart from "@salesforce/apex/ShoppingCartController.updateSh */ // Store each state change in the cart history -export const cartHistory = $signal([]); +export const cartHistory = $signal([], {track: true}); +$effect(() => { + console.log('cartHistory', JSON.stringify(cartHistory.value, null, 2)); +}); let isUndoing = false; export const undoCartChange = () => { isUndoing = true; const lastState = cartHistory.value[cartHistory.value.length - 1]; // Remove the last state from the history - cartHistory.value = cartHistory.value.slice(0, -1); + cartHistory.value.splice(-1, 1); if (lastState) { updateCart(lastState); } @@ -55,7 +58,7 @@ async function updateCartOnTheServer(newCart, previousValue, mutate) { // Store the previous value in the history if (shouldUpdateHistory) { - cartHistory.value = [...cartHistory.value, previousValue]; + cartHistory.value.push(previousValue); } } catch (error) { mutate(null, error); diff --git a/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js b/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js index eb26855..fe539ec 100644 --- a/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js +++ b/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js @@ -1,5 +1,5 @@ import TwElement from "c/twElement"; -import { $computed } from "c/signals"; +import { $computed, $effect } from "c/signals"; import { shoppingCart } from "c/demoSignals"; // States @@ -7,7 +7,13 @@ import ready from "./states/ready.html"; import loading from "./states/loading.html"; export default class CheckoutButton extends TwElement { - itemData = $computed(() => (this.itemData = shoppingCart.value)).value; + itemData = shoppingCart.value; + + connectedCallback() { + $effect(() => { + this.itemData = shoppingCart.value; + }); + } render() { return this.itemData.loading ? loading : ready; diff --git a/force-app/lwc/signals/core.js b/force-app/lwc/signals/core.js index f6a04f4..3b3c302 100644 --- a/force-app/lwc/signals/core.js +++ b/force-app/lwc/signals/core.js @@ -115,8 +115,11 @@ function $signal(value, options) { // The Observable Membrane proxies the passed in object to track changes // to objects and arrays, but this introduces a performance overhead. const shouldTrack = options?.track ?? false; - const trackableState = shouldTrack ? new TrackedState(value, notifySubscribers) : new UntrackedState(value); - const _storageOption = options?.storage?.(trackableState.get()) ?? useInMemoryStorage(trackableState.get()); + const trackableState = shouldTrack + ? new TrackedState(value, notifySubscribers) + : new UntrackedState(value); + const _storageOption = options?.storage?.(trackableState.get()) ?? + useInMemoryStorage(trackableState.get()); const subscribers = new Set(); function getter() { const current = _getCurrentObserver(); diff --git a/force-app/lwc/signals/observable-membrane/base-handler.js b/force-app/lwc/signals/observable-membrane/base-handler.js index 409b0ee..e74317b 100644 --- a/force-app/lwc/signals/observable-membrane/base-handler.js +++ b/force-app/lwc/signals/observable-membrane/base-handler.js @@ -1,16 +1,132 @@ /* eslint-disable */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck +/* + * Copyright (c) 2023, Salesforce.com, Inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { ArrayConcat, getPrototypeOf, getOwnPropertyNames, getOwnPropertySymbols, getOwnPropertyDescriptor, isUndefined, isExtensible, hasOwnProperty, ObjectDefineProperty, preventExtensions, ArrayPush, ObjectCreate, } from './shared'; export 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 - get(_shadowTarget, key) { + // 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); + } } diff --git a/force-app/lwc/signals/observable-membrane/main.js b/force-app/lwc/signals/observable-membrane/main.js index e690692..5ed17d4 100644 --- a/force-app/lwc/signals/observable-membrane/main.js +++ b/force-app/lwc/signals/observable-membrane/main.js @@ -1,3 +1,6 @@ +/* eslint-disable */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck /* * Copyright (c) 2023, Salesforce.com, Inc. * All rights reserved. diff --git a/force-app/lwc/signals/observable-membrane/observable-membrane.js b/force-app/lwc/signals/observable-membrane/observable-membrane.js index d8108af..ad6aeb0 100644 --- a/force-app/lwc/signals/observable-membrane/observable-membrane.js +++ b/force-app/lwc/signals/observable-membrane/observable-membrane.js @@ -27,10 +27,10 @@ function defaultValueIsObservable(value) { const proto = getPrototypeOf(value); return proto === ObjectDotPrototype || proto === null || getPrototypeOf(proto) === null; } -const defaultValueObserved = () => { +const defaultValueObserved = (obj, key) => { /* do nothing */ }; -const defaultValueMutated = () => { +const defaultValueMutated = (obj, key) => { /* do nothing */ }; function createShadowTarget(value) { @@ -51,7 +51,7 @@ export class ObservableMembrane { getProxy(value) { const unwrappedValue = unwrap(value); if (this.valueIsObservable(unwrappedValue)) { - // When trying to extract the writable version of a readonly, we return the readonly. + // When trying to extract the writable version of a readonly we return the readonly. if (this.readOnlyObjectGraph.get(unwrappedValue) === value) { return value; } @@ -66,6 +66,9 @@ export class ObservableMembrane { } return value; } + unwrapProxy(p) { + return unwrap(p); + } getReactiveHandler(value) { let proxy = this.reactiveObjectGraph.get(value); if (isUndefined(proxy)) { diff --git a/force-app/lwc/signals/observable-membrane/reactive-dev-formatter.js b/force-app/lwc/signals/observable-membrane/reactive-dev-formatter.js index c70e60e..defc647 100644 --- a/force-app/lwc/signals/observable-membrane/reactive-dev-formatter.js +++ b/force-app/lwc/signals/observable-membrane/reactive-dev-formatter.js @@ -51,6 +51,7 @@ const formatter = { }; // Inspired from paulmillr/es6-shim // https://github.com/paulmillr/es6-shim/blob/master/es6-shim.js#L176-L185 +/* istanbul ignore next */ function getGlobal() { // the only reliable means to get the global object is `Function('return this')()` // However, this causes CSP violations in Chrome apps. diff --git a/force-app/lwc/signals/observable-membrane/reactive-handler.js b/force-app/lwc/signals/observable-membrane/reactive-handler.js index c12666b..110c8d9 100644 --- a/force-app/lwc/signals/observable-membrane/reactive-handler.js +++ b/force-app/lwc/signals/observable-membrane/reactive-handler.js @@ -7,7 +7,7 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { isArray, unwrap, isUndefined, } from './shared'; +import { toString, isArray, unwrap, isExtensible, preventExtensions, ObjectDefineProperty, hasOwnProperty, isUndefined, } from './shared'; import { BaseProxyHandler } from './base-handler'; const getterMap = new WeakMap(); const setterMap = new WeakMap(); @@ -44,7 +44,51 @@ export class ReactiveProxyHandler extends BaseProxyHandler { reverseSetterMap.set(set, originalSet); return set; } - set(_shadowTarget, key, value) { + 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.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.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) { @@ -52,12 +96,57 @@ export class ReactiveProxyHandler extends BaseProxyHandler { 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, + // 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) { + throw new Error(`Invalid setPrototypeOf invocation for reactive proxy ${toString(this.originalTarget)}. Prototype of reactive objects cannot be changed.`); + } + 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; + } } diff --git a/force-app/lwc/signals/observable-membrane/read-only-handler.js b/force-app/lwc/signals/observable-membrane/read-only-handler.js index c86cd60..ef20947 100644 --- a/force-app/lwc/signals/observable-membrane/read-only-handler.js +++ b/force-app/lwc/signals/observable-membrane/read-only-handler.js @@ -7,8 +7,8 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { unwrap, isArray, isUndefined } from "./shared"; -import { BaseProxyHandler } from "./base-handler"; +import { unwrap, isArray, isUndefined } from './shared'; +import { BaseProxyHandler } from './base-handler'; const getterMap = new WeakMap(); const setterMap = new WeakMap(); export class ReadOnlyHandler extends BaseProxyHandler { @@ -34,18 +34,34 @@ export class ReadOnlyHandler extends BaseProxyHandler { return wrappedSetter; } const handler = this; - const set = function () { + const set = function (v) { const { originalTarget } = handler; throw new Error(`Invalid mutation: Cannot invoke a setter on "${originalTarget}". "${originalTarget}" is read-only.`); }; setterMap.set(originalSet, set); return set; } - set(_shadowTarget, key) { + set(shadowTarget, key, value) { const { originalTarget } = this; const msg = isArray(originalTarget) ? `Invalid mutation: Cannot mutate array at index ${key.toString()}. Array is read-only.` : `Invalid mutation: Cannot set "${key.toString()}" on "${originalTarget}". "${originalTarget}" is read-only.`; throw new Error(msg); } + deleteProperty(shadowTarget, key) { + const { originalTarget } = this; + throw new Error(`Invalid mutation: Cannot delete "${key.toString()}" on "${originalTarget}". "${originalTarget}" is read-only.`); + } + setPrototypeOf(shadowTarget, prototype) { + const { originalTarget } = this; + throw new Error(`Invalid prototype mutation: Cannot set prototype on "${originalTarget}". "${originalTarget}" prototype is read-only.`); + } + preventExtensions(shadowTarget) { + const { originalTarget } = this; + throw new Error(`Invalid mutation: Cannot preventExtensions on ${originalTarget}". "${originalTarget} is read-only.`); + } + defineProperty(shadowTarget, key, descriptor) { + const { originalTarget } = this; + throw new Error(`Invalid mutation: Cannot defineProperty "${key.toString()}" on "${originalTarget}". "${originalTarget}" is read-only.`); + } } diff --git a/src/lwc/signals/__tests__/computed.test.ts b/src/lwc/signals/__tests__/computed.test.ts index 7c7eda9..1e8d674 100644 --- a/src/lwc/signals/__tests__/computed.test.ts +++ b/src/lwc/signals/__tests__/computed.test.ts @@ -1,4 +1,4 @@ -import { $computed, $signal } from "../core"; +import { $computed, $signal, $effect } from "../core"; describe("computed values", () => { test("can be created from a source signal", () => { @@ -13,7 +13,7 @@ describe("computed values", () => { }); test("are recomputed when the source is an object and has changes when the signal is being tracked", () => { - const signal = $signal({ a: 0 }, { track: true }); + const signal = $signal({ a: 0, b: 1 }, { track: true }); const computed = $computed(() => signal.value.a * 2); expect(computed.value).toBe(0); @@ -72,7 +72,6 @@ describe("computed values", () => { expect(computed.value).toBe(0); }); - test("are not recomputed when the source is an array with gets a push when the signal is not tracked", () => { const signal = $signal([0]); const computed = $computed(() => signal.value.length); diff --git a/src/lwc/signals/core.ts b/src/lwc/signals/core.ts index 267d3e7..bb3baaa 100644 --- a/src/lwc/signals/core.ts +++ b/src/lwc/signals/core.ts @@ -89,6 +89,7 @@ type SignalOptions = { interface TrackableState { get(): T; + set(value: T): void; } @@ -161,10 +162,13 @@ function $signal( // The Observable Membrane proxies the passed in object to track changes // to objects and arrays, but this introduces a performance overhead. const shouldTrack = options?.track ?? false; - const trackableState: TrackableState = shouldTrack ? new TrackedState(value, notifySubscribers) : new UntrackedState(value); + const trackableState: TrackableState = shouldTrack + ? new TrackedState(value, notifySubscribers) + : new UntrackedState(value); const _storageOption: State = - options?.storage?.(trackableState.get()) ?? useInMemoryStorage(trackableState.get()); + options?.storage?.(trackableState.get()) ?? + useInMemoryStorage(trackableState.get()); const subscribers: Set = new Set(); function getter() { diff --git a/src/lwc/signals/observable-membrane/base-handler.ts b/src/lwc/signals/observable-membrane/base-handler.ts index 7a4a1f5..bb7e4ab 100644 --- a/src/lwc/signals/observable-membrane/base-handler.ts +++ b/src/lwc/signals/observable-membrane/base-handler.ts @@ -1,7 +1,6 @@ /* eslint-disable */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck - /* * Copyright (c) 2023, Salesforce.com, Inc. * All rights reserved. @@ -9,6 +8,18 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ import { + ArrayConcat, + getPrototypeOf, + getOwnPropertyNames, + getOwnPropertySymbols, + getOwnPropertyDescriptor, + isUndefined, + isExtensible, + hasOwnProperty, + ObjectDefineProperty, + preventExtensions, + ArrayPush, + ObjectCreate, ProxyPropertyKey, } from './shared'; import { ObservableMembrane } from './observable-membrane'; @@ -30,12 +41,78 @@ export abstract class BaseProxyHandler { abstract wrapGetter(originalGet: () => any): () => any; abstract wrapSetter(originalSet: (v: any) => void): (v: any) => void; + // Shared utility methods + + wrapDescriptor(descriptor: PropertyDescriptor): PropertyDescriptor { + 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: ShadowTarget, key: ProxyPropertyKey) { + 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: ShadowTarget): void { + const { originalTarget } = this; + const targetKeys: ProxyPropertyKey[] = ArrayConcat.call( + getOwnPropertyNames(originalTarget), + getOwnPropertySymbols(originalTarget), + ); + targetKeys.forEach((key: ProxyPropertyKey) => { + this.copyDescriptorIntoShadowTarget(shadowTarget, key); + }); + const { + membrane: { tagPropertyKey }, + } = this; + if (!isUndefined(tagPropertyKey) && !hasOwnProperty.call(shadowTarget, tagPropertyKey)) { + ObjectDefineProperty(shadowTarget, tagPropertyKey, ObjectCreate(null)); + } + preventExtensions(shadowTarget); + } + // Abstract Traps abstract set(shadowTarget: ShadowTarget, key: ProxyPropertyKey, value: any): boolean; + abstract deleteProperty(shadowTarget: ShadowTarget, key: ProxyPropertyKey): boolean; + abstract setPrototypeOf(shadowTarget: ShadowTarget, prototype: any): any; + abstract preventExtensions(shadowTarget: ShadowTarget): boolean; + abstract defineProperty( + shadowTarget: ShadowTarget, + key: ProxyPropertyKey, + descriptor: PropertyDescriptor, + ): boolean; // Shared Traps - get(_shadowTarget: ShadowTarget, key: ProxyPropertyKey): any { + + // TODO: apply() is never called + /* istanbul ignore next */ + apply(shadowTarget: ShadowTarget, thisArg: any, argArray: any[]) { + /* No op */ + } + // TODO: construct() is never called + /* istanbul ignore next */ + construct(shadowTarget: ShadowTarget, argArray: any, newTarget?: any): any { + /* No op */ + } + get(shadowTarget: ShadowTarget, key: ProxyPropertyKey): any { const { originalTarget, membrane: { valueObserved }, @@ -44,4 +121,77 @@ export abstract class BaseProxyHandler { valueObserved(originalTarget, key); return this.wrapValue(value); } + has(shadowTarget: ShadowTarget, key: ProxyPropertyKey): boolean { + 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: ShadowTarget): ProxyPropertyKey[] { + 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: ShadowTarget): boolean { + 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: ShadowTarget): object { + const { originalTarget } = this; + return getPrototypeOf(originalTarget); + } + getOwnPropertyDescriptor( + shadowTarget: ShadowTarget, + key: ProxyPropertyKey, + ): PropertyDescriptor | undefined { + 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); + } } diff --git a/src/lwc/signals/observable-membrane/main.ts b/src/lwc/signals/observable-membrane/main.ts index e690692..5ed17d4 100644 --- a/src/lwc/signals/observable-membrane/main.ts +++ b/src/lwc/signals/observable-membrane/main.ts @@ -1,3 +1,6 @@ +/* eslint-disable */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck /* * Copyright (c) 2023, Salesforce.com, Inc. * All rights reserved. diff --git a/src/lwc/signals/observable-membrane/observable-membrane.ts b/src/lwc/signals/observable-membrane/observable-membrane.ts index 4acaff4..9610d43 100644 --- a/src/lwc/signals/observable-membrane/observable-membrane.ts +++ b/src/lwc/signals/observable-membrane/observable-membrane.ts @@ -1,7 +1,6 @@ /* eslint-disable */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck - /* * Copyright (c) 2023, Salesforce.com, Inc. * All rights reserved. @@ -54,10 +53,10 @@ function defaultValueIsObservable(value: any): boolean { return proto === ObjectDotPrototype || proto === null || getPrototypeOf(proto) === null; } -const defaultValueObserved: ValueObservedCallback = () => { +const defaultValueObserved: ValueObservedCallback = (obj: any, key: ProxyPropertyKey) => { /* do nothing */ }; -const defaultValueMutated: ValueMutatedCallback = () => { +const defaultValueMutated: ValueMutatedCallback = (obj: any, key: ProxyPropertyKey) => { /* do nothing */ }; @@ -87,7 +86,7 @@ export class ObservableMembrane { getProxy(value: any) { const unwrappedValue = unwrap(value); if (this.valueIsObservable(unwrappedValue)) { - // When trying to extract the writable version of a readonly, we return the readonly. + // When trying to extract the writable version of a readonly we return the readonly. if (this.readOnlyObjectGraph.get(unwrappedValue) === value) { return value; } @@ -104,6 +103,10 @@ export class ObservableMembrane { return value; } + unwrapProxy(p: any) { + return unwrap(p); + } + private getReactiveHandler(value: any): any { let proxy = this.reactiveObjectGraph.get(value); if (isUndefined(proxy)) { diff --git a/src/lwc/signals/observable-membrane/reactive-dev-formatter.ts b/src/lwc/signals/observable-membrane/reactive-dev-formatter.ts index 13bb20c..683dc47 100644 --- a/src/lwc/signals/observable-membrane/reactive-dev-formatter.ts +++ b/src/lwc/signals/observable-membrane/reactive-dev-formatter.ts @@ -1,7 +1,6 @@ /* eslint-disable */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck - /* * Copyright (c) 2023, Salesforce.com, Inc. * All rights reserved. @@ -20,7 +19,7 @@ import { ProxyPropertyKey, } from './shared'; -// Define globalThis since it's not currently defined in by TypeScript. +// Define globalThis since it's not current defined in by typescript. // https://github.com/tc39/proposal-global declare var globalThis: any; @@ -79,6 +78,7 @@ const formatter: DevToolFormatter = { // Inspired from paulmillr/es6-shim // https://github.com/paulmillr/es6-shim/blob/master/es6-shim.js#L176-L185 +/* istanbul ignore next */ function getGlobal(): any { // the only reliable means to get the global object is `Function('return this')()` // However, this causes CSP violations in Chrome apps. diff --git a/src/lwc/signals/observable-membrane/reactive-handler.ts b/src/lwc/signals/observable-membrane/reactive-handler.ts index 63c3cbb..ca28281 100644 --- a/src/lwc/signals/observable-membrane/reactive-handler.ts +++ b/src/lwc/signals/observable-membrane/reactive-handler.ts @@ -1,7 +1,6 @@ /* eslint-disable */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck - /* * Copyright (c) 2023, Salesforce.com, Inc. * All rights reserved. @@ -9,8 +8,13 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ import { + toString, isArray, unwrap, + isExtensible, + preventExtensions, + ObjectDefineProperty, + hasOwnProperty, isUndefined, ProxyPropertyKey, } from './shared'; @@ -52,7 +56,50 @@ export class ReactiveProxyHandler extends BaseProxyHandler { reverseSetterMap.set(set, originalSet); return set; } - set(_shadowTarget: ShadowTarget, key: ProxyPropertyKey, value: any): boolean { + unwrapDescriptor(descriptor: PropertyDescriptor): PropertyDescriptor { + 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: () => any): () => any { + const reverseGetter = reverseGetterMap.get(redGet); + if (!isUndefined(reverseGetter)) { + return reverseGetter; + } + const handler = this; + const get = function (this: any): any { + // invoking the red getter with the proxy of this + return unwrap(redGet.call(handler.wrapValue(this))); + }; + getterMap.set(get, redGet); + reverseGetterMap.set(redGet, get); + return get; + } + unwrapSetter(redSet: (v: any) => void): (v: any) => void { + const reverseSetter = reverseSetterMap.get(redSet); + if (!isUndefined(reverseSetter)) { + return reverseSetter; + } + const handler = this; + const set = function (this: any, v: any) { + // invoking the red setter with the proxy of this + redSet.call(handler.wrapValue(this), handler.wrapValue(v)); + }; + setterMap.set(set, redSet); + reverseSetterMap.set(redSet, set); + return set; + } + set(shadowTarget: ShadowTarget, key: ProxyPropertyKey, value: any): boolean { const { originalTarget, membrane: { valueMutated }, @@ -62,12 +109,71 @@ export class ReactiveProxyHandler extends BaseProxyHandler { 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, + // 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: ShadowTarget, key: ProxyPropertyKey): boolean { + const { + originalTarget, + membrane: { valueMutated }, + } = this; + delete originalTarget[key]; + valueMutated(originalTarget, key); + return true; + } + setPrototypeOf(shadowTarget: ShadowTarget, prototype: any): any { + throw new Error( + `Invalid setPrototypeOf invocation for reactive proxy ${toString( + this.originalTarget, + )}. Prototype of reactive objects cannot be changed.`, + ); + } + preventExtensions(shadowTarget: ShadowTarget): boolean { + 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: ShadowTarget, + key: ProxyPropertyKey, + descriptor: PropertyDescriptor, + ): boolean { + 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; + } } diff --git a/src/lwc/signals/observable-membrane/read-only-handler.ts b/src/lwc/signals/observable-membrane/read-only-handler.ts index df871a3..f299419 100644 --- a/src/lwc/signals/observable-membrane/read-only-handler.ts +++ b/src/lwc/signals/observable-membrane/read-only-handler.ts @@ -1,59 +1,83 @@ /* eslint-disable */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck - /* * Copyright (c) 2023, Salesforce.com, Inc. * All rights reserved. * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { unwrap, isArray, isUndefined, ProxyPropertyKey } from "./shared"; -import { BaseProxyHandler, ShadowTarget } from "./base-handler"; +import { unwrap, isArray, isUndefined, ProxyPropertyKey } from './shared'; +import { BaseProxyHandler, ShadowTarget } from './base-handler'; const getterMap = new WeakMap<() => any, () => any>(); const setterMap = new WeakMap<(v: any) => void, (v: any) => void>(); export class ReadOnlyHandler extends BaseProxyHandler { - wrapValue(value: any): any { - return this.membrane.getReadOnlyProxy(value); - } - - wrapGetter(originalGet: () => any): () => any { - const wrappedGetter = getterMap.get(originalGet); - if (!isUndefined(wrappedGetter)) { - return wrappedGetter; - } - const handler = this; - const get = function (this: any): any { - // invoking the original getter with the original target - return handler.wrapValue(originalGet.call(unwrap(this))); - }; - getterMap.set(originalGet, get); - return get; - } - - wrapSetter(originalSet: (v: any) => void): (v: any) => void { - const wrappedSetter = setterMap.get(originalSet); - if (!isUndefined(wrappedSetter)) { - return wrappedSetter; - } - const handler = this; - const set = function (this: any) { - const { originalTarget } = handler; - throw new Error( - `Invalid mutation: Cannot invoke a setter on "${originalTarget}". "${originalTarget}" is read-only.` - ); - }; - setterMap.set(originalSet, set); - return set; - } - - set(_shadowTarget: ShadowTarget, key: ProxyPropertyKey): boolean { - const { originalTarget } = this; - const msg = isArray(originalTarget) - ? `Invalid mutation: Cannot mutate array at index ${key.toString()}. Array is read-only.` - : `Invalid mutation: Cannot set "${key.toString()}" on "${originalTarget}". "${originalTarget}" is read-only.`; - throw new Error(msg); - } + wrapValue(value: any): any { + return this.membrane.getReadOnlyProxy(value); + } + wrapGetter(originalGet: () => any): () => any { + const wrappedGetter = getterMap.get(originalGet); + if (!isUndefined(wrappedGetter)) { + return wrappedGetter; + } + const handler = this; + const get = function (this: any): any { + // invoking the original getter with the original target + return handler.wrapValue(originalGet.call(unwrap(this))); + }; + getterMap.set(originalGet, get); + return get; + } + wrapSetter(originalSet: (v: any) => void): (v: any) => void { + const wrappedSetter = setterMap.get(originalSet); + if (!isUndefined(wrappedSetter)) { + return wrappedSetter; + } + const handler = this; + const set = function (this: any, v: any) { + const { originalTarget } = handler; + throw new Error( + `Invalid mutation: Cannot invoke a setter on "${originalTarget}". "${originalTarget}" is read-only.`, + ); + }; + setterMap.set(originalSet, set); + return set; + } + set(shadowTarget: ShadowTarget, key: ProxyPropertyKey, value: any): boolean { + const { originalTarget } = this; + const msg = isArray(originalTarget) + ? `Invalid mutation: Cannot mutate array at index ${key.toString()}. Array is read-only.` + : `Invalid mutation: Cannot set "${key.toString()}" on "${originalTarget}". "${originalTarget}" is read-only.`; + throw new Error(msg); + } + deleteProperty(shadowTarget: ShadowTarget, key: ProxyPropertyKey): boolean { + const { originalTarget } = this; + throw new Error( + `Invalid mutation: Cannot delete "${key.toString()}" on "${originalTarget}". "${originalTarget}" is read-only.`, + ); + } + setPrototypeOf(shadowTarget: ShadowTarget, prototype: any): any { + const { originalTarget } = this; + throw new Error( + `Invalid prototype mutation: Cannot set prototype on "${originalTarget}". "${originalTarget}" prototype is read-only.`, + ); + } + preventExtensions(shadowTarget: ShadowTarget): boolean { + const { originalTarget } = this; + throw new Error( + `Invalid mutation: Cannot preventExtensions on ${originalTarget}". "${originalTarget} is read-only.`, + ); + } + defineProperty( + shadowTarget: ShadowTarget, + key: ProxyPropertyKey, + descriptor: PropertyDescriptor, + ): boolean { + const { originalTarget } = this; + throw new Error( + `Invalid mutation: Cannot defineProperty "${key.toString()}" on "${originalTarget}". "${originalTarget}" is read-only.`, + ); + } } diff --git a/src/lwc/signals/observable-membrane/shared.ts b/src/lwc/signals/observable-membrane/shared.ts index fb330ed..8271ae2 100644 --- a/src/lwc/signals/observable-membrane/shared.ts +++ b/src/lwc/signals/observable-membrane/shared.ts @@ -1,7 +1,6 @@ /* eslint-disable */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck - /* * Copyright (c) 2023, Salesforce.com, Inc. * All rights reserved.