diff --git a/README.md b/README.md index a76700a..9ba0dc5 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,28 @@ export const counterPlusTwo = $computed(() => counterPlusOne.value + 1); Because `$computed` values return a signal, you can use them as you would use any other signal. +## Tracking objects and arrays + +By default, for a signal to be reactive it needs to be reassigned. This can be cumbersome when dealing with objects +and arrays, as you would need to reassign the whole object or array to trigger the reactivity. + +To improve that experience, you can set the `track` flag to true when creating the signal. This will make the signal +reactive to changes in the object or array properties. + +> 📒 Think about this as using the `@track` decorator in LWC properties. It works the exact same way behind the scenes. + +```javascript +import { $signal } from "c/signals"; + +const obj = $signal({ x: 1, y: 2 }, { track: true }); +const computedFromObj = $computed(() => obj.value.x + obj.value.y); + +// When a value in the object changes, the computed value will automatically update +obj.value.x = 2; + +console.log(computedFromObj.value); // 4 +``` + ## Reacting to multiple signals You can also use multiple signals in a single `computed` and react to changes in any of them. diff --git a/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.html b/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.html index 3aaca44..3872b64 100644 --- a/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.html +++ b/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.html @@ -6,4 +6,4 @@
Contact Name: {contactInfo.contactName}
- \ No newline at end of file + diff --git a/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.js b/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.js index 622417a..f3277cc 100644 --- a/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.js +++ b/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.js @@ -10,4 +10,4 @@ export default class BusinessCard extends LightningElement { contactName: contactName.value }) ).value; -} \ No newline at end of file +} diff --git a/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.js-meta.xml b/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.js-meta.xml index acf1f27..5e6eba0 100644 --- a/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.js-meta.xml +++ b/examples/computed-from-multiple-signals/lwc/businessCard/businessCard.js-meta.xml @@ -8,4 +8,4 @@ lightningCommunity__Default lightningCommunity__Page - \ No newline at end of file + diff --git a/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.html b/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.html index 83b1d89..40160b0 100644 --- a/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.html +++ b/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.html @@ -10,4 +10,4 @@ value={contactName} onchange={handleContactNameChange} > - \ No newline at end of file + diff --git a/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.js b/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.js index d948c82..a801bc2 100644 --- a/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.js +++ b/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.js @@ -13,4 +13,4 @@ export default class ContactInfoForm extends LightningElement { handleContactNameChange(event) { contactName.value = event.target.value; } -} \ No newline at end of file +} diff --git a/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.js-meta.xml b/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.js-meta.xml index 953579a..82f0043 100644 --- a/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.js-meta.xml +++ b/examples/computed-from-multiple-signals/lwc/contactInfoForm/contactInfoForm.js-meta.xml @@ -8,4 +8,4 @@ lightningCommunity__Default lightningCommunity__Page - \ No newline at end of file + diff --git a/examples/counter/lwc/countChanger/countChanger.html b/examples/counter/lwc/countChanger/countChanger.html index 77b37c1..06b510b 100644 --- a/examples/counter/lwc/countChanger/countChanger.html +++ b/examples/counter/lwc/countChanger/countChanger.html @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/examples/counter/lwc/countChanger/countChanger.js b/examples/counter/lwc/countChanger/countChanger.js index 09a3099..4b172b8 100644 --- a/examples/counter/lwc/countChanger/countChanger.js +++ b/examples/counter/lwc/countChanger/countChanger.js @@ -9,4 +9,4 @@ export default class CountChanger extends LightningElement { decrementCount() { counter.value--; } -} \ No newline at end of file +} diff --git a/examples/counter/lwc/countTracker/countTracker.html b/examples/counter/lwc/countTracker/countTracker.html index 674de33..44645ee 100644 --- a/examples/counter/lwc/countTracker/countTracker.html +++ b/examples/counter/lwc/countTracker/countTracker.html @@ -1,4 +1,4 @@ \ No newline at end of file + diff --git a/examples/counter/lwc/countTracker/countTracker.js b/examples/counter/lwc/countTracker/countTracker.js index 6329155..db10620 100644 --- a/examples/counter/lwc/countTracker/countTracker.js +++ b/examples/counter/lwc/countTracker/countTracker.js @@ -8,4 +8,4 @@ export default class CountTracker extends LightningElement { counterPlusTwo = $computed(() => (this.counterPlusTwo = counterPlusTwo.value)) .value; -} \ No newline at end of file +} diff --git a/examples/demo-signals/lwc/demoSignals/contact-info.js b/examples/demo-signals/lwc/demoSignals/contact-info.js index f51de82..ee69883 100644 --- a/examples/demo-signals/lwc/demoSignals/contact-info.js +++ b/examples/demo-signals/lwc/demoSignals/contact-info.js @@ -2,4 +2,4 @@ import { $signal } from "c/signals"; export const accountName = $signal("ACME"); -export const contactName = $signal("John Doe"); \ No newline at end of file +export const contactName = $signal("John Doe"); diff --git a/examples/demo-signals/lwc/demoSignals/demoSignals.js-meta.xml b/examples/demo-signals/lwc/demoSignals/demoSignals.js-meta.xml index 9cc72ec..175ca14 100644 --- a/examples/demo-signals/lwc/demoSignals/demoSignals.js-meta.xml +++ b/examples/demo-signals/lwc/demoSignals/demoSignals.js-meta.xml @@ -4,4 +4,4 @@ Demo Signals false Demo Signals - \ No newline at end of file + diff --git a/examples/demo-signals/lwc/demoSignals/shopping-cart.js b/examples/demo-signals/lwc/demoSignals/shopping-cart.js index eeeb16f..6a4c6c1 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/server-communication/classes/ResourceController.cls-meta.xml b/examples/server-communication/classes/ResourceController.cls-meta.xml index f5e18fd..df13efa 100644 --- a/examples/server-communication/classes/ResourceController.cls-meta.xml +++ b/examples/server-communication/classes/ResourceController.cls-meta.xml @@ -1,4 +1,4 @@ - + 60.0 Active diff --git a/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.html b/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.html index 9653fa3..e8d1612 100644 --- a/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.html +++ b/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.html @@ -3,7 +3,10 @@

Selected Account

- \ No newline at end of file + diff --git a/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.js b/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.js index 665543c..c2a38ce 100644 --- a/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.js +++ b/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.js @@ -4,4 +4,4 @@ import { getAccount } from "c/demoSignals"; export default class DisplaySelectedAccount extends LightningElement { account = $computed(() => (this.account = getAccount.value)).value; -} \ No newline at end of file +} diff --git a/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.js-meta.xml b/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.js-meta.xml index b2680f6..10a1986 100644 --- a/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.js-meta.xml +++ b/examples/server-communication/lwc/displaySelectedAccount/displaySelectedAccount.js-meta.xml @@ -1,4 +1,4 @@ - + 60.0 Display Selected Account @@ -8,4 +8,4 @@ lightningCommunity__Default lightningCommunity__Page - \ No newline at end of file + diff --git a/examples/server-communication/lwc/listAccounts/listAccounts.html b/examples/server-communication/lwc/listAccounts/listAccounts.html index 78d93b7..18f66f8 100644 --- a/examples/server-communication/lwc/listAccounts/listAccounts.html +++ b/examples/server-communication/lwc/listAccounts/listAccounts.html @@ -6,4 +6,4 @@ options={accounts} onchange={handleAccountChange} > - \ No newline at end of file + diff --git a/examples/server-communication/lwc/listAccounts/listAccounts.js b/examples/server-communication/lwc/listAccounts/listAccounts.js index eedb476..a885e4e 100644 --- a/examples/server-communication/lwc/listAccounts/listAccounts.js +++ b/examples/server-communication/lwc/listAccounts/listAccounts.js @@ -28,4 +28,4 @@ export default class ListAccounts extends LightningElement { handleAccountChange(event) { selectedAccountId.value = event.detail.value; } -} \ No newline at end of file +} diff --git a/examples/server-communication/lwc/listAccounts/listAccounts.js-meta.xml b/examples/server-communication/lwc/listAccounts/listAccounts.js-meta.xml index 64e404d..3f9ab75 100644 --- a/examples/server-communication/lwc/listAccounts/listAccounts.js-meta.xml +++ b/examples/server-communication/lwc/listAccounts/listAccounts.js-meta.xml @@ -1,4 +1,4 @@ - + 60.0 List Accounts @@ -8,4 +8,4 @@ lightningCommunity__Default lightningCommunity__Page - \ No newline at end of file + diff --git a/examples/server-communication/lwc/serverFetcher/serverFetcher.html b/examples/server-communication/lwc/serverFetcher/serverFetcher.html index 4b4d549..17984d5 100644 --- a/examples/server-communication/lwc/serverFetcher/serverFetcher.html +++ b/examples/server-communication/lwc/serverFetcher/serverFetcher.html @@ -1,8 +1,6 @@ \ No newline at end of file + diff --git a/examples/server-communication/lwc/serverFetcher/serverFetcher.js-meta.xml b/examples/server-communication/lwc/serverFetcher/serverFetcher.js-meta.xml index 9866124..04b602c 100644 --- a/examples/server-communication/lwc/serverFetcher/serverFetcher.js-meta.xml +++ b/examples/server-communication/lwc/serverFetcher/serverFetcher.js-meta.xml @@ -1,4 +1,4 @@ - + 60.0 Server Fetcher @@ -8,4 +8,4 @@ lightningCommunity__Default lightningCommunity__Page - \ No newline at end of file + diff --git a/examples/shopping-cart/controllers/ShoppingCartController.cls-meta.xml b/examples/shopping-cart/controllers/ShoppingCartController.cls-meta.xml index f5e18fd..df13efa 100644 --- a/examples/shopping-cart/controllers/ShoppingCartController.cls-meta.xml +++ b/examples/shopping-cart/controllers/ShoppingCartController.cls-meta.xml @@ -1,4 +1,4 @@ - + 60.0 Active diff --git a/examples/shopping-cart/lwc/checkoutButton/checkoutButton.html b/examples/shopping-cart/lwc/checkoutButton/checkoutButton.html index 41a40c8..cc340bc 100644 --- a/examples/shopping-cart/lwc/checkoutButton/checkoutButton.html +++ b/examples/shopping-cart/lwc/checkoutButton/checkoutButton.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js b/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js index 209f871..fe539ec 100644 --- a/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js +++ b/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js @@ -1,13 +1,19 @@ import TwElement from "c/twElement"; -import {$computed} from 'c/signals'; -import {shoppingCart} from "c/demoSignals"; +import { $computed, $effect } from "c/signals"; +import { shoppingCart } from "c/demoSignals"; // States 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; @@ -16,4 +22,4 @@ export default class CheckoutButton extends TwElement { get isEmpty() { return this.itemData.data.items.length === 0; } -} \ No newline at end of file +} diff --git a/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js-meta.xml b/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js-meta.xml index ec3011f..68a3bea 100644 --- a/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js-meta.xml +++ b/examples/shopping-cart/lwc/checkoutButton/checkoutButton.js-meta.xml @@ -1,4 +1,4 @@ - + 60.0 Checkout Button @@ -8,4 +8,4 @@ lightningCommunity__Default lightningCommunity__Page - \ No newline at end of file + diff --git a/examples/shopping-cart/lwc/checkoutButton/states/loading.html b/examples/shopping-cart/lwc/checkoutButton/states/loading.html index c985c2f..2d3c959 100644 --- a/examples/shopping-cart/lwc/checkoutButton/states/loading.html +++ b/examples/shopping-cart/lwc/checkoutButton/states/loading.html @@ -1,5 +1,5 @@ \ No newline at end of file +
+ +
+ diff --git a/examples/shopping-cart/lwc/checkoutButton/states/ready.html b/examples/shopping-cart/lwc/checkoutButton/states/ready.html index 81b4d39..4871f42 100644 --- a/examples/shopping-cart/lwc/checkoutButton/states/ready.html +++ b/examples/shopping-cart/lwc/checkoutButton/states/ready.html @@ -1,24 +1,23 @@ \ No newline at end of file + + diff --git a/examples/shopping-cart/lwc/shoppingCartDetails/shoppingCartDetails.js-meta.xml b/examples/shopping-cart/lwc/shoppingCartDetails/shoppingCartDetails.js-meta.xml index 5e20735..e8f3ae7 100644 --- a/examples/shopping-cart/lwc/shoppingCartDetails/shoppingCartDetails.js-meta.xml +++ b/examples/shopping-cart/lwc/shoppingCartDetails/shoppingCartDetails.js-meta.xml @@ -1,4 +1,4 @@ - + 60.0 Shopping Cart Details diff --git a/examples/shopping-cart/lwc/shoppingCartDetails/states/loading.html b/examples/shopping-cart/lwc/shoppingCartDetails/states/loading.html index 6309482..4b18256 100644 --- a/examples/shopping-cart/lwc/shoppingCartDetails/states/loading.html +++ b/examples/shopping-cart/lwc/shoppingCartDetails/states/loading.html @@ -1,6 +1,6 @@ diff --git a/examples/shopping-cart/lwc/stencil/stencil.css b/examples/shopping-cart/lwc/stencil/stencil.css index c48a6f7..06707a4 100644 --- a/examples/shopping-cart/lwc/stencil/stencil.css +++ b/examples/shopping-cart/lwc/stencil/stencil.css @@ -8,23 +8,28 @@ padding: 0; height: inherit; overflow: hidden; - position: relative; - background-color: #e2e2e2; + position: relative; + background-color: #e2e2e2; } .loading::after { - display: block; - content: ''; - position: absolute; - width: 100%; - height: 100%; - transform: translateX(-100%); - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .2), transparent); - animation: loading 1.5s infinite; + display: block; + content: ""; + position: absolute; + width: 100%; + height: 100%; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.2), + transparent + ); + animation: loading 1.5s infinite; } @keyframes loading { - 100% { - transform: translateX(100%); - } -} \ No newline at end of file + 100% { + transform: translateX(100%); + } +} diff --git a/examples/shopping-cart/lwc/stencil/stencil.html b/examples/shopping-cart/lwc/stencil/stencil.html index c2bc2c9..93849ee 100644 --- a/examples/shopping-cart/lwc/stencil/stencil.html +++ b/examples/shopping-cart/lwc/stencil/stencil.html @@ -1,7 +1,11 @@ \ No newline at end of file + diff --git a/examples/shopping-cart/lwc/stencil/stencil.js b/examples/shopping-cart/lwc/stencil/stencil.js index 51ef137..eee1095 100644 --- a/examples/shopping-cart/lwc/stencil/stencil.js +++ b/examples/shopping-cart/lwc/stencil/stencil.js @@ -1,4 +1,4 @@ -import { LightningElement, api } from 'lwc'; +import { LightningElement, api } from "lwc"; /** * Stencil class for `c-stencil` component. @@ -43,7 +43,7 @@ export default class Stencil extends LightningElement { * @access public * @default medium */ - @api weightVariant = 'medium'; + @api weightVariant = "medium"; get containerStyle() { return `${this.containerHeight}; ${this.containerWidth}; ${this.containerRadius}`; @@ -55,7 +55,7 @@ export default class Stencil extends LightningElement { get containerWidth() { if (!this.width) { - return 'width: 100%'; + return "width: 100%"; } return `width: ${this.width}px`; @@ -63,10 +63,10 @@ export default class Stencil extends LightningElement { get containerRadius() { if (!this.circle) { - return 'border-radius: 0.25rem'; + return "border-radius: 0.25rem"; } - return 'border-radius: 50%'; + return "border-radius: 50%"; } get items() { @@ -79,16 +79,16 @@ export default class Stencil extends LightningElement { } get loadingBackgroundColor() { - if (this.weightVariant === 'light') { - return 'background-color: #f3f2f2'; + if (this.weightVariant === "light") { + return "background-color: #f3f2f2"; } - if (this.weightVariant === 'medium') { - return 'background-color: #e2e2e2'; + if (this.weightVariant === "medium") { + return "background-color: #e2e2e2"; } - if (this.weightVariant === 'dark') { - return 'background-color: #ccc'; + if (this.weightVariant === "dark") { + return "background-color: #ccc"; } } -} \ No newline at end of file +} diff --git a/examples/shopping-cart/lwc/stencil/stencil.js-meta.xml b/examples/shopping-cart/lwc/stencil/stencil.js-meta.xml index ec9b323..0f1cd8f 100644 --- a/examples/shopping-cart/lwc/stencil/stencil.js-meta.xml +++ b/examples/shopping-cart/lwc/stencil/stencil.js-meta.xml @@ -1,7 +1,7 @@ - + 60.0 Stencil false Stencil - \ No newline at end of file + diff --git a/examples/shopping-cart/lwc/twElement/twElement.html b/examples/shopping-cart/lwc/twElement/twElement.html index c77e9ec..312775a 100644 --- a/examples/shopping-cart/lwc/twElement/twElement.html +++ b/examples/shopping-cart/lwc/twElement/twElement.html @@ -3,5 +3,4 @@ --> - \ No newline at end of file + diff --git a/examples/shopping-cart/lwc/twElement/twElement.js b/examples/shopping-cart/lwc/twElement/twElement.js index 36d2bf8..3bd407b 100644 --- a/examples/shopping-cart/lwc/twElement/twElement.js +++ b/examples/shopping-cart/lwc/twElement/twElement.js @@ -1,7 +1,7 @@ -import {LightningElement} from 'lwc'; +import { LightningElement } from "lwc"; import { loadStyle } from "lightning/platformResourceLoader"; -import tw from '@salesforce/resourceUrl/tw'; +import tw from "@salesforce/resourceUrl/tw"; export default class TwElement extends LightningElement { connectedCallback() { @@ -10,4 +10,4 @@ export default class TwElement extends LightningElement { this.template.host.style.opacity = "1"; }); } -} \ No newline at end of file +} diff --git a/examples/shopping-cart/lwc/twElement/twElement.js-meta.xml b/examples/shopping-cart/lwc/twElement/twElement.js-meta.xml index 09c3d7b..62813d1 100644 --- a/examples/shopping-cart/lwc/twElement/twElement.js-meta.xml +++ b/examples/shopping-cart/lwc/twElement/twElement.js-meta.xml @@ -1,7 +1,7 @@ - + 60.0 Tw Element false Tw Element - \ No newline at end of file + diff --git a/force-app/lwc/signals/core.js b/force-app/lwc/signals/core.js index 30308af..3b3c302 100644 --- a/force-app/lwc/signals/core.js +++ b/force-app/lwc/signals/core.js @@ -1,5 +1,6 @@ import { useInMemoryStorage } from "./use"; import { debounce } from "./utils"; +import { ObservableMembrane } from "./observable-membrane/observable-membrane"; const context = []; function _getCurrentObserver() { return context[context.length - 1]; @@ -59,6 +60,33 @@ function $computed(fn) { }); return computedSignal.readOnly; } +class UntrackedState { + constructor(value) { + this._value = value; + } + get() { + return this._value; + } + set(value) { + this._value = value; + } +} +class TrackedState { + constructor(value, onChangeCallback) { + this._membrane = new ObservableMembrane({ + valueMutated() { + onChangeCallback(); + } + }); + this._value = this._membrane.getProxy(value); + } + get() { + return this._value; + } + set(value) { + this._value = this._membrane.getProxy(value); + } +} /** * Creates a new signal with the provided value. A signal is a reactive * primitive that can be used to store and update values. Signals can be @@ -83,7 +111,15 @@ function $computed(fn) { * @param options Options to configure the signal */ function $signal(value, options) { - const _storageOption = options?.storage?.(value) ?? useInMemoryStorage(value); + // Defaults to not tracking changes through the Observable Membrane. + // 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 subscribers = new Set(); function getter() { const current = _getCurrentObserver(); @@ -96,7 +132,8 @@ function $signal(value, options) { if (newValue === _storageOption.get()) { return; } - _storageOption.set(newValue); + trackableState.set(newValue); + _storageOption.set(trackableState.get()); notifySubscribers(); } function notifySubscribers() { diff --git a/force-app/lwc/signals/observable-membrane/base-handler.js b/force-app/lwc/signals/observable-membrane/base-handler.js new file mode 100644 index 0000000..e74317b --- /dev/null +++ b/force-app/lwc/signals/observable-membrane/base-handler.js @@ -0,0 +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 + // 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 new file mode 100644 index 0000000..5ed17d4 --- /dev/null +++ b/force-app/lwc/signals/observable-membrane/main.js @@ -0,0 +1,10 @@ +/* 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 + */ +export { ObservableMembrane } from './observable-membrane'; diff --git a/force-app/lwc/signals/observable-membrane/observable-membrane.js b/force-app/lwc/signals/observable-membrane/observable-membrane.js new file mode 100644 index 0000000..ad6aeb0 --- /dev/null +++ b/force-app/lwc/signals/observable-membrane/observable-membrane.js @@ -0,0 +1,94 @@ +/* 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, getPrototypeOf, isFunction, registerProxy, ObjectDotPrototype, } from './shared'; +import { ReactiveProxyHandler } from './reactive-handler'; +import { ReadOnlyHandler } from './read-only-handler'; +import { init as initDevFormatter } from './reactive-dev-formatter'; +initDevFormatter(); +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) ? [] : {}; +} +export 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; + } +} diff --git a/force-app/lwc/signals/observable-membrane/reactive-dev-formatter.js b/force-app/lwc/signals/observable-membrane/reactive-dev-formatter.js new file mode 100644 index 0000000..defc647 --- /dev/null +++ b/force-app/lwc/signals/observable-membrane/reactive-dev-formatter.js @@ -0,0 +1,82 @@ +/* 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 { ArrayPush, ArrayConcat, isArray, ObjectCreate, getPrototypeOf, getOwnPropertyNames, getOwnPropertySymbols, unwrap, } from './shared'; +function extract(objectOrArray) { + if (isArray(objectOrArray)) { + return objectOrArray.map((item) => { + const original = unwrap(item); + if (original !== item) { + return extract(original); + } + return item; + }); + } + const obj = ObjectCreate(getPrototypeOf(objectOrArray)); + const names = getOwnPropertyNames(objectOrArray); + return ArrayConcat.call(names, getOwnPropertySymbols(objectOrArray)).reduce((seed, key) => { + const item = objectOrArray[key]; + const original = unwrap(item); + if (original !== item) { + seed[key] = extract(original); + } + else { + seed[key] = item; + } + return seed; + }, obj); +} +const formatter = { + header: (plainOrProxy) => { + const originalTarget = unwrap(plainOrProxy); + // if originalTarget is falsy or not unwrappable, exit + if (!originalTarget || originalTarget === plainOrProxy) { + return null; + } + const obj = extract(plainOrProxy); + return ['object', { object: obj }]; + }, + hasBody: () => { + return false; + }, + body: () => { + return null; + }, +}; +// 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. + if (typeof globalThis !== 'undefined') { + return globalThis; + } + if (typeof self !== 'undefined') { + return self; + } + if (typeof window !== 'undefined') { + return window; + } + if (typeof global !== 'undefined') { + return global; + } + // Gracefully degrade if not able to locate the global object + return {}; +} +export function init() { + const global = getGlobal(); + // Custom Formatter for Dev Tools. To enable this, open Chrome Dev Tools + // - Go to Settings, + // - Under console, select "Enable custom formatters" + // For more information, https://docs.google.com/document/d/1FTascZXT9cxfetuPRT2eXPQKXui4nWFivUnS_335T3U/preview + const devtoolsFormatters = global.devtoolsFormatters || []; + ArrayPush.call(devtoolsFormatters, formatter); + global.devtoolsFormatters = devtoolsFormatters; +} diff --git a/force-app/lwc/signals/observable-membrane/reactive-handler.js b/force-app/lwc/signals/observable-membrane/reactive-handler.js new file mode 100644 index 0000000..110c8d9 --- /dev/null +++ b/force-app/lwc/signals/observable-membrane/reactive-handler.js @@ -0,0 +1,152 @@ +/* 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 { toString, isArray, unwrap, isExtensible, preventExtensions, ObjectDefineProperty, hasOwnProperty, isUndefined, } from './shared'; +import { BaseProxyHandler } from './base-handler'; +const getterMap = new WeakMap(); +const setterMap = new WeakMap(); +const reverseGetterMap = new WeakMap(); +const reverseSetterMap = new WeakMap(); +export class ReactiveProxyHandler extends BaseProxyHandler { + wrapValue(value) { + return this.membrane.getProxy(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); + reverseGetterMap.set(get, originalGet); + return get; + } + wrapSetter(originalSet) { + const wrappedSetter = setterMap.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.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.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) { + 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) { + 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 new file mode 100644 index 0000000..ef20947 --- /dev/null +++ b/force-app/lwc/signals/observable-membrane/read-only-handler.js @@ -0,0 +1,67 @@ +/* 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 } from './shared'; +import { BaseProxyHandler } from './base-handler'; +const getterMap = new WeakMap(); +const setterMap = new WeakMap(); +export 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 handler = this; + 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, 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/force-app/lwc/signals/observable-membrane/shared.js b/force-app/lwc/signals/observable-membrane/shared.js new file mode 100644 index 0000000..156f5ae --- /dev/null +++ b/force-app/lwc/signals/observable-membrane/shared.js @@ -0,0 +1,36 @@ +/* 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 + */ +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; +export { ArrayPush, ArrayConcat, isArray, getPrototypeOf, ObjectCreate, ObjectDefineProperty, ObjectDotPrototype, isExtensible, getOwnPropertyDescriptor, getOwnPropertyNames, getOwnPropertySymbols, preventExtensions, hasOwnProperty, }; +const OtS = {}.toString; +export function toString(obj) { + if (obj && obj.toString) { + return obj.toString(); + } + else if (typeof obj === 'object') { + return OtS.call(obj); + } + else { + return obj + ''; + } +} +export function isUndefined(obj) { + return obj === undefined; +} +export function isFunction(obj) { + return typeof obj === 'function'; +} +const proxyToValueMap = new WeakMap(); +export function registerProxy(proxy, value) { + proxyToValueMap.set(proxy, value); +} +export const unwrap = (replicaOrAny) => proxyToValueMap.get(replicaOrAny) || replicaOrAny; diff --git a/force-app/lwc/signals/signals.js-meta.xml b/force-app/lwc/signals/signals.js-meta.xml index 48a3735..0488e66 100644 --- a/force-app/lwc/signals/signals.js-meta.xml +++ b/force-app/lwc/signals/signals.js-meta.xml @@ -4,4 +4,4 @@ Signals true Signals - \ No newline at end of file + diff --git a/force-app/lwc/signals/use.js b/force-app/lwc/signals/use.js index 42db942..9ae761e 100644 --- a/force-app/lwc/signals/use.js +++ b/force-app/lwc/signals/use.js @@ -1,114 +1,107 @@ -import { - subscribe, - unsubscribe as empApiUnsubscribe, - isEmpEnabled, - onError as empApiOnError -} from "lightning/empApi"; +import { subscribe, unsubscribe as empApiUnsubscribe, isEmpEnabled, onError as empApiOnError } from "lightning/empApi"; export function createStorage(get, set, registerOnChange, unsubscribe) { - return { get, set, registerOnChange, unsubscribe }; + return { get, set, registerOnChange, unsubscribe }; } export function useInMemoryStorage(value) { - let _value = value; - function getter() { - return _value; - } - function setter(newValue) { - _value = newValue; - } - return createStorage(getter, setter); -} -function useLocalStorageCreator(key, value) { - function getter() { - const item = localStorage.getItem(key); - if (item) { - return JSON.parse(item); + let _value = value; + function getter() { + return _value; } - return value; - } - function setter(newValue) { - localStorage.setItem(key, JSON.stringify(newValue)); - } - // Set initial value if not set - if (!localStorage.getItem(key)) { - localStorage.setItem(key, JSON.stringify(value)); - } - return createStorage(getter, setter); -} -export function useLocalStorage(key) { - return function (value) { - return useLocalStorageCreator(key, value); - }; -} -function useSessionStorageCreator(key, value) { - function getter() { - const item = sessionStorage.getItem(key); - if (item) { - return JSON.parse(item); + function setter(newValue) { + _value = newValue; } - return value; - } - function setter(newValue) { - sessionStorage.setItem(key, JSON.stringify(newValue)); - } - // Set initial value if not set - if (!sessionStorage.getItem(key)) { - sessionStorage.setItem(key, JSON.stringify(value)); - } - return createStorage(getter, setter); -} -export function useSessionStorage(key) { - return function (value) { - return useSessionStorageCreator(key, value); - }; + return createStorage(getter, setter); } -export function useCookies(key, expires) { - return function (value) { +function useLocalStorageCreator(key, value) { function getter() { - const cookie = document.cookie - .split("; ") - .find((row) => row.startsWith(key)); - if (cookie) { - const value = cookie.replace(`${key}=`, ""); - return JSON.parse(value); - } - return value; + const item = localStorage.getItem(key); + if (item) { + return JSON.parse(item); + } + return value; } function setter(newValue) { - document.cookie = `${key}=${JSON.stringify(newValue)}; expires=${expires?.toUTCString()}`; + localStorage.setItem(key, JSON.stringify(newValue)); + } + // Set initial value if not set + if (!localStorage.getItem(key)) { + localStorage.setItem(key, JSON.stringify(value)); } return createStorage(getter, setter); - }; } -export function useEventListener(type) { - return function (value) { - let _value = value; - let _onChange; - window.addEventListener(type, (event) => { - const e = event; - _value = e.detail.data; - if (e.detail.sender !== "__internal__") { - _onChange?.(); - } - }); +export function useLocalStorage(key) { + return function (value) { + return useLocalStorageCreator(key, value); + }; +} +function useSessionStorageCreator(key, value) { function getter() { - return _value; + const item = sessionStorage.getItem(key); + if (item) { + return JSON.parse(item); + } + return value; } function setter(newValue) { - _value = newValue; - window.dispatchEvent( - new CustomEvent(type, { - detail: { - data: newValue, - sender: "__internal__" - } - }) - ); + sessionStorage.setItem(key, JSON.stringify(newValue)); } - function registerOnChange(onChange) { - _onChange = onChange; + // Set initial value if not set + if (!sessionStorage.getItem(key)) { + sessionStorage.setItem(key, JSON.stringify(value)); } - return createStorage(getter, setter, registerOnChange); - }; + return createStorage(getter, setter); +} +export function useSessionStorage(key) { + return function (value) { + return useSessionStorageCreator(key, value); + }; +} +export function useCookies(key, expires) { + return function (value) { + function getter() { + const cookie = document.cookie + .split("; ") + .find((row) => row.startsWith(key)); + if (cookie) { + const value = cookie.replace(`${key}=`, ""); + return JSON.parse(value); + } + return value; + } + function setter(newValue) { + document.cookie = `${key}=${JSON.stringify(newValue)}; expires=${expires?.toUTCString()}`; + } + return createStorage(getter, setter); + }; +} +export function useEventListener(type) { + return function (value) { + let _value = value; + let _onChange; + window.addEventListener(type, (event) => { + const e = event; + _value = e.detail.data; + if (e.detail.sender !== "__internal__") { + _onChange?.(); + } + }); + function getter() { + return _value; + } + function setter(newValue) { + _value = newValue; + window.dispatchEvent(new CustomEvent(type, { + detail: { + data: newValue, + sender: "__internal__" + } + })); + } + function registerOnChange(onChange) { + _onChange = onChange; + } + return createStorage(getter, setter, registerOnChange); + }; } /** * Subscribes to the event bus channel (e.g. platform event, change data capture, etc.). @@ -153,41 +146,39 @@ export function useEventListener(type) { * handshake, connect, subscribe, and unsubscribe meta channels. */ export function useEventBus(channel, toValue, options) { - return function (value) { - let _value = value; - let _onChange; - let subscription = {}; - const replayId = options?.replayId ?? -1; - isEmpEnabled().then((enabled) => { - if (!enabled) { - console.error( - `EMP API is not enabled, cannot subscribe to channel ${channel}` - ); - return; - } - subscribe(channel, replayId, (response) => { - _value = toValue(response); - _onChange?.(); - }).then((sub) => { - subscription = sub; - options?.onSubscribe?.(sub); - }); - empApiOnError((error) => { - options?.onError?.(error); - }); - }); - function getter() { - return _value; - } - function setter(newValue) { - _value = newValue; - } - function registerOnChange(onChange) { - _onChange = onChange; - } - function unsubscribe(callback) { - return empApiUnsubscribe(subscription, callback); - } - return createStorage(getter, setter, registerOnChange, unsubscribe); - }; + return function (value) { + let _value = value; + let _onChange; + let subscription = {}; + const replayId = options?.replayId ?? -1; + isEmpEnabled().then((enabled) => { + if (!enabled) { + console.error(`EMP API is not enabled, cannot subscribe to channel ${channel}`); + return; + } + subscribe(channel, replayId, (response) => { + _value = toValue(response); + _onChange?.(); + }).then((sub) => { + subscription = sub; + options?.onSubscribe?.(sub); + }); + empApiOnError((error) => { + options?.onError?.(error); + }); + }); + function getter() { + return _value; + } + function setter(newValue) { + _value = newValue; + } + function registerOnChange(onChange) { + _onChange = onChange; + } + function unsubscribe(callback) { + return empApiUnsubscribe(subscription, callback); + } + return createStorage(getter, setter, registerOnChange, unsubscribe); + }; } diff --git a/force-app/lwc/signals/utils.js b/force-app/lwc/signals/utils.js index 5077fe7..4651f16 100644 --- a/force-app/lwc/signals/utils.js +++ b/force-app/lwc/signals/utils.js @@ -4,6 +4,6 @@ export function debounce(func, delay) { if (debounceTimer) { clearTimeout(debounceTimer); } - debounceTimer = setTimeout(() => func(...args), delay); + debounceTimer = window.setTimeout(() => func(...args), delay); }; } diff --git a/src/lwc/signals/__tests__/computed.test.ts b/src/lwc/signals/__tests__/computed.test.ts new file mode 100644 index 0000000..4666178 --- /dev/null +++ b/src/lwc/signals/__tests__/computed.test.ts @@ -0,0 +1,49 @@ +import { $computed, $signal } from "../core"; + +describe("computed values", () => { + test("can be created from a source signal", () => { + const signal = $signal(0); + const computed = $computed(() => signal.value * 2); + + expect(computed.value).toBe(0); + + signal.value = 1; + + expect(computed.value).toBe(2); + }); + + test("do not recompute when the same value is set in the source signal", () => { + const signal = $signal(0); + + let timesComputed = 0; + const computed = $computed(() => { + timesComputed++; + return signal.value * 2; + }); + + expect(computed.value).toBe(0); + expect(timesComputed).toBe(1); + + signal.value = 1; + + expect(computed.value).toBe(2); + expect(timesComputed).toBe(2); + + signal.value = 1; + + expect(computed.value).toBe(2); + expect(timesComputed).toBe(2); + }); + + test("can be created from another computed value", () => { + const signal = $signal(0); + const computed = $computed(() => signal.value * 2); + const anotherComputed = $computed(() => computed.value * 2); + expect(anotherComputed.value).toBe(0); + + signal.value = 1; + + expect(computed.value).toBe(2); + expect(anotherComputed.value).toBe(4); + }); +}); diff --git a/src/lwc/signals/__tests__/custom-storage.test.ts b/src/lwc/signals/__tests__/custom-storage.test.ts new file mode 100644 index 0000000..479199e --- /dev/null +++ b/src/lwc/signals/__tests__/custom-storage.test.ts @@ -0,0 +1,124 @@ +import { createStorage, useCookies, useEventBus, useLocalStorage, useSessionStorage } from "../use"; +import { $signal, Signal } from "../core"; +import { jestMockPublish } from "../../../__mocks__/lightning/empApi"; + +test("signals can be expanded with a user created custom storages", () => { + const useUndo = (value: T) => { + const _valueStack: T[] = []; + + // add the initial value to the stack + _valueStack.push(value); + + function undo() { + _valueStack.pop(); + } + + const customStorage = createStorage( + () => { + // Get value at the top of the stack + return _valueStack[_valueStack.length - 1]; + }, + (newValue) => { + _valueStack.push(newValue); + } + ); + + return { + ...customStorage, + undo + }; + }; + + const signal = $signal(0, { + storage: useUndo + }) as unknown as Signal & { undo: () => void }; + + expect(signal.value).toBe(0); + + signal.value = 1; + expect(signal.value).toBe(1); + + signal.value = 2; + expect(signal.value).toBe(2); + + signal.undo(); + expect(signal.value).toBe(1); + + signal.undo(); + expect(signal.value).toBe(0); +}); + +describe("when storing a value in local storage", () => { + it("has a default value", () => { + const signal = $signal(0, { + storage: useLocalStorage("test") + }); + expect(signal.value).toBe(0); + }); + + it("allows you to update the value", () => { + const signal = $signal(0); + signal.value = 1; + expect(signal.value).toBe(1); + }); +}); + +describe("storing values in session storage", () => { + test("should have a default value", () => { + const signal = $signal(0, { + storage: useSessionStorage("test") + }); + expect(signal.value).toBe(0); + }); + + test("should update the value", () => { + const signal = $signal(0); + signal.value = 1; + expect(signal.value).toBe(1); + }); +}); + +describe("storing values in cookies", () => { + test("should have a default value", () => { + const signal = $signal(0, { + storage: useCookies("test") + }); + expect(signal.value).toBe(0); + }); + + test("should update the value", () => { + const signal = $signal(0, { + storage: useCookies("test") + }); + signal.value = 1; + expect(signal.value).toBe(1); + }); +}); + +describe("when receiving a value from the empApi", () => { + it("should update the signal when the message is received", async () => { + function handleEvent(event?: { data: { payload: Record } }) { + return event?.data.payload.Message__c ?? ""; + } + + const signal = $signal("", { + storage: useEventBus("/event/TestChannel__e", handleEvent) + }); + + await new Promise(process.nextTick); + + expect(signal.value).toBe(""); + + await jestMockPublish("/event/TestChannel__e", { + data: { + payload: { + Message__c: "Hello World!" + } + } + }); + + await new Promise(process.nextTick); + + expect(signal.value).toBe("Hello World!"); + }); +}); diff --git a/src/lwc/signals/__tests__/effect.test.ts b/src/lwc/signals/__tests__/effect.test.ts new file mode 100644 index 0000000..ff43aff --- /dev/null +++ b/src/lwc/signals/__tests__/effect.test.ts @@ -0,0 +1,17 @@ +import { $signal, $effect } from "../core"; + +describe("effects", () => { + test("react to updates in a signal", () => { + const signal = $signal(0); + let effectTracker = 0; + + $effect(() => { + effectTracker = signal.value; + }); + + expect(effectTracker).toBe(0); + + signal.value = 1; + expect(effectTracker).toBe(1); + }); +}); diff --git a/src/lwc/signals/__tests__/resource.test.ts b/src/lwc/signals/__tests__/resource.test.ts new file mode 100644 index 0000000..33ec2f2 --- /dev/null +++ b/src/lwc/signals/__tests__/resource.test.ts @@ -0,0 +1,395 @@ +import { $resource, $signal } from "../core"; + +describe('resources', () => { + test("can can be created by providing an async function", async () => { + const asyncFunction = async () => { + return "done"; + }; + + const { data: resource } = $resource(asyncFunction); + + expect(resource.value).toEqual({ + data: null, + loading: true, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: "done", + loading: false, + error: null + }); + }); + + test("can pass along parameters to the provided function", async () => { + const asyncFunction = async (params?: { [key: string]: unknown }) => { + return params?.["source"]; + }; + + const { data: resource } = $resource(asyncFunction, { source: 1 }); + + expect(resource.value).toEqual({ + data: null, + loading: true, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: 1, + loading: false, + error: null + }); + }); + + test("can be created with an initial value", async () => { + const asyncFunction = async () => { + return "done"; + }; + + const { data: resource } = $resource(asyncFunction, undefined, { + initialValue: "initial" + }); + + expect(resource.value).toEqual({ + data: "initial", + loading: true, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: "done", + loading: false, + error: null + }); + }); + + test("get reevaluated when a reactive source is provided", async () => { + const asyncFunction = async (params?: { [key: string]: unknown }) => { + return params?.["source"]; + }; + + const source = $signal(0); + const { data: resource } = $resource(asyncFunction, () => ({ + source: source.value + })); + + expect(resource.value).toEqual({ + data: null, + loading: true, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: 0, + loading: false, + error: null + }); + + source.value = 1; + + expect(resource.value).toEqual({ + data: 0, + loading: true, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: 1, + loading: false, + error: null + }); + }); + + test("can be mutated", async () => { + const asyncFunction = async () => { + return "done"; + }; + + const { data: resource, mutate } = $resource(asyncFunction); + + expect(resource.value).toEqual({ + data: null, + loading: true, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: "done", + loading: false, + error: null + }); + + mutate("mutated"); + + expect(resource.value).toEqual({ + data: "mutated", + loading: false, + error: null + }); + }); + + test("are not reevaluated when optimistic updating is not turned on and no onMutate is provided", async () => { + const asyncFunction = async () => { + return "done"; + }; + + const { data: resource, mutate } = $resource(asyncFunction, undefined, { + optimisticMutate: false + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: "done", + loading: false, + error: null + }); + + mutate("mutated"); + + expect(resource.value).toEqual({ + data: "done", + loading: false, + error: null + }); + }); + + test("react to a mutation", async () => { + const asyncFunction = async () => { + return "done"; + }; + + let hasReacted = false; + const reactionFunction = () => { + hasReacted = true; + }; + + const { mutate } = $resource(asyncFunction, undefined, { + onMutate: reactionFunction + }); + + await new Promise(process.nextTick); + + mutate("mutated"); + + await new Promise(process.nextTick); + + expect(hasReacted).toBe(true); + }); + + test("can be mutated and change the value on success", async () => { + const asyncFunction = async () => { + return "done"; + }; + + const asyncReaction = async (newValue: string, __: string | null, mutate: (value: string | null, error?: unknown) => void) => { + mutate(`${newValue} - post async success`); + }; + + const { data: resource, mutate } = $resource(asyncFunction, undefined, { + onMutate: asyncReaction + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: "done", + loading: false, + error: null + }); + + mutate("mutated"); + + expect(resource.value).toEqual({ + data: "mutated - post async success", + loading: false, + error: null + }); + }); + + test("can be provided a callback on mutate that can set errors", async () => { + const asyncFunction = async () => { + return "done"; + }; + + const asyncReaction = async (newValue: string, _: string | null, mutate: (value: string | null, error?: unknown) => void) => { + mutate(null, "An error occurred"); + }; + + const { data: resource, mutate } = $resource(asyncFunction, undefined, { + onMutate: asyncReaction + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: "done", + loading: false, + error: null + }); + + mutate("mutated"); + + expect(resource.value).toEqual({ + data: null, + loading: false, + error: "An error occurred" + }); + }); + + test("can be forced to reevaluate", async () => { + let counter = 0; + const asyncFunction = async () => { + return counter++; + }; + + const { data: resource, refetch } = $resource(asyncFunction); + + expect(resource.value).toEqual({ + data: null, + loading: true, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: 0, + loading: false, + error: null + }); + + refetch(); + + expect(resource.value).toEqual({ + data: 0, + loading: true, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: 1, + loading: false, + error: null + }); + }); + + test("does not fetch when a fetchWhen option is passed that evaluates to false", async () => { + const asyncFunction = async (params?: { [key: string]: unknown }) => { + return params?.["source"]; + }; + + const source = $signal("changed"); + const { data: resource } = $resource(asyncFunction, () => ({ + source: source.value + }), + { + initialValue: "initial", + fetchWhen: () => false + }); + + expect(resource.value).toEqual({ + data: "initial", + loading: false, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: "initial", + loading: false, + error: null + }); + }); + + test("fetches when a fetchWhen option is passed that evaluates to true", async () => { + const asyncFunction = async (params?: { [key: string]: unknown }) => { + return params?.["source"]; + }; + + const source = $signal("changed"); + const { data: resource } = $resource(asyncFunction, () => ({ + source: source.value + }), + { + initialValue: "initial", + fetchWhen: () => true + }); + + expect(resource.value).toEqual({ + data: "initial", + loading: true, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: "changed", + loading: false, + error: null + }); + }); + + test("fetches when the fetchWhen option is passed reevaluates to true", async () => { + const asyncFunction = async (params?: { [key: string]: unknown }) => { + return params?.["source"]; + }; + + const flagSignal = $signal(false); + const source = $signal("changed"); + const { data: resource } = $resource(asyncFunction, () => ({ + source: source.value + }), + { + initialValue: "initial", + fetchWhen: () => flagSignal.value + }); + + expect(resource.value).toEqual({ + data: "initial", + loading: false, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: "initial", + loading: false, + error: null + }); + + flagSignal.value = true; + + expect(resource.value).toEqual({ + data: "initial", + loading: true, + error: null + }); + + await new Promise(process.nextTick); + + expect(resource.value).toEqual({ + data: "changed", + loading: false, + error: null + }); + }); +}); + diff --git a/src/lwc/signals/__tests__/signals.test.ts b/src/lwc/signals/__tests__/signals.test.ts index 0985b30..e06a1fa 100644 --- a/src/lwc/signals/__tests__/signals.test.ts +++ b/src/lwc/signals/__tests__/signals.test.ts @@ -1,601 +1,30 @@ -import { $signal, $computed, $effect, $resource, Signal } from "../core"; -import { createStorage, useCookies, useEventBus, useLocalStorage, useSessionStorage } from "../use"; -import { jestMockPublish } from "../../../__mocks__/lightning/empApi"; +import { $signal } from "../core"; describe("signals", () => { - describe("core functionality", () => { - test("should have a default value", () => { - const signal = $signal(0); - expect(signal.value).toBe(0); - }); - - test("should update the value", () => { - const signal = $signal(0); - signal.value = 1; - expect(signal.value).toBe(1); - }); - - test("can debounce setting a signal value", async () => { - const debouncedSignal = $signal(0, { - debounce: 100 - }); - - debouncedSignal.value = 1; - - expect(debouncedSignal.value).toBe(0); - - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(debouncedSignal.value).toBe(1); - }); - - test("can derive a computed value", () => { - const signal = $signal(0); - const computed = $computed(() => signal.value * 2); - expect(computed.value).toBe(0); - signal.value = 1; - expect(computed.value).toBe(2); - }); - - test("does not recompute when the same value is set", () => { - const signal = $signal(0); - - let timesComputed = 0; - const computed = $computed(() => { - timesComputed++; - return signal.value * 2; - }); - - expect(computed.value).toBe(0); - expect(timesComputed).toBe(1); - - signal.value = 1; - - expect(computed.value).toBe(2); - expect(timesComputed).toBe(2); - - signal.value = 1; - - expect(computed.value).toBe(2); - expect(timesComputed).toBe(2); - }); - - test("can derive a computed value from another computed value", () => { - const signal = $signal(0); - const computed = $computed(() => signal.value * 2); - const anotherComputed = $computed(() => computed.value * 2); - expect(anotherComputed.value).toBe(0); - - signal.value = 1; - - expect(computed.value).toBe(2); - expect(anotherComputed.value).toBe(4); - }); - - test("can create an effect", () => { - const signal = $signal(0); - let effectTracker = 0; - - $effect(() => { - effectTracker = signal.value; - }); - - expect(effectTracker).toBe(0); - - signal.value = 1; - expect(effectTracker).toBe(1); - }); - - test("can create a resource using an async function", async () => { - const asyncFunction = async () => { - return "done"; - }; - - const { data: resource } = $resource(asyncFunction); - - expect(resource.value).toEqual({ - data: null, - loading: true, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: "done", - loading: false, - error: null - }); - }); - - test("can create a resource using an async function with params", async () => { - const asyncFunction = async (params?: { [key: string]: unknown }) => { - return params?.["source"]; - }; - - const { data: resource } = $resource(asyncFunction, { source: 1 }); - - expect(resource.value).toEqual({ - data: null, - loading: true, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: 1, - loading: false, - error: null - }); - }); - - test("can create a resource using an async function and set an initial value", async () => { - const asyncFunction = async () => { - return "done"; - }; - - const { data: resource } = $resource(asyncFunction, undefined, { - initialValue: "initial" - }); - - expect(resource.value).toEqual({ - data: "initial", - loading: true, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: "done", - loading: false, - error: null - }); - }); - - test("can create a resource using an async function with a reactive source", async () => { - const asyncFunction = async (params?: { [key: string]: unknown }) => { - return params?.["source"]; - }; - - const source = $signal(0); - const { data: resource } = $resource(asyncFunction, () => ({ - source: source.value - })); - - expect(resource.value).toEqual({ - data: null, - loading: true, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: 0, - loading: false, - error: null - }); - - source.value = 1; - - expect(resource.value).toEqual({ - data: 0, - loading: true, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: 1, - loading: false, - error: null - }); - }); - - test("can mutate a resource", async () => { - const asyncFunction = async () => { - return "done"; - }; - - const { data: resource, mutate } = $resource(asyncFunction); - - expect(resource.value).toEqual({ - data: null, - loading: true, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: "done", - loading: false, - error: null - }); - - mutate("mutated"); - - expect(resource.value).toEqual({ - data: "mutated", - loading: false, - error: null - }); - }); - - test("does not mutate a resource if optimistic updating is not turned on and no onMutate is provided", async () => { - const asyncFunction = async () => { - return "done"; - }; - - const { data: resource, mutate } = $resource(asyncFunction, undefined, { - optimisticMutate: false - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: "done", - loading: false, - error: null - }); - - mutate("mutated"); - - expect(resource.value).toEqual({ - data: "done", - loading: false, - error: null - }); - }); - - test("can react to a mutation", async () => { - const asyncFunction = async () => { - return "done"; - }; - - let hasReacted = false; - const reactionFunction = () => { - hasReacted = true; - }; - - const { mutate } = $resource(asyncFunction, undefined, { - onMutate: reactionFunction - }); - - await new Promise(process.nextTick); - - mutate("mutated"); - - await new Promise(process.nextTick); - - expect(hasReacted).toBe(true); - }); - - test("can mutate a resource and change the value on success", async () => { - const asyncFunction = async () => { - return "done"; - }; - - const asyncReaction = async (newValue: string, __: string | null, mutate: (value: string | null, error?: unknown) => void) => { - mutate(`${newValue} - post async success`); - }; - - const { data: resource, mutate } = $resource(asyncFunction, undefined, { - onMutate: asyncReaction - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: "done", - loading: false, - error: null - }); - - mutate("mutated"); - - expect(resource.value).toEqual({ - data: "mutated - post async success", - loading: false, - error: null - }); - }); - - test("the onMutate function can set an error", async () => { - const asyncFunction = async () => { - return "done"; - }; - - const asyncReaction = async (newValue: string, _: string | null, mutate: (value: string | null, error?: unknown) => void) => { - mutate(null, "An error occurred"); - }; - - const { data: resource, mutate } = $resource(asyncFunction, undefined, { - onMutate: asyncReaction - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: "done", - loading: false, - error: null - }); - - mutate("mutated"); - - expect(resource.value).toEqual({ - data: null, - loading: false, - error: "An error occurred" - }); - }); - }); - - test("can force a refetch of a resource", async () => { - let counter = 0; - const asyncFunction = async () => { - return counter++; - }; - - const { data: resource, refetch } = $resource(asyncFunction); - - expect(resource.value).toEqual({ - data: null, - loading: true, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: 0, - loading: false, - error: null - }); - - refetch(); - - expect(resource.value).toEqual({ - data: 0, - loading: true, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: 1, - loading: false, - error: null - }); - }); - - test("when the fetchWhen option is passed, it does not fetch when it evaluates to false", async () => { - const asyncFunction = async (params?: { [key: string]: unknown }) => { - return params?.["source"]; - }; - - const source = $signal("changed"); - const { data: resource } = $resource(asyncFunction, () => ({ - source: source.value - }), - { - initialValue: "initial", - fetchWhen: () => false - }); - - expect(resource.value).toEqual({ - data: "initial", - loading: false, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: "initial", - loading: false, - error: null - }); - }); - - test("when the fetchWhen option is passed, it fetches when it evaluates to true", async () => { - const asyncFunction = async (params?: { [key: string]: unknown }) => { - return params?.["source"]; - }; - - const source = $signal("changed"); - const { data: resource } = $resource(asyncFunction, () => ({ - source: source.value - }), - { - initialValue: "initial", - fetchWhen: () => true - }); - - expect(resource.value).toEqual({ - data: "initial", - loading: true, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: "changed", - loading: false, - error: null - }); - }); - - test("when the fetchWhen option is passed, it fetches when its value changes to true", async () => { - const asyncFunction = async (params?: { [key: string]: unknown }) => { - return params?.["source"]; - }; - - const flagSignal = $signal(false); - const source = $signal("changed"); - const { data: resource } = $resource(asyncFunction, () => ({ - source: source.value - }), - { - initialValue: "initial", - fetchWhen: () => flagSignal.value - }); - - expect(resource.value).toEqual({ - data: "initial", - loading: false, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: "initial", - loading: false, - error: null - }); - - flagSignal.value = true; - - expect(resource.value).toEqual({ - data: "initial", - loading: true, - error: null - }); - - await new Promise(process.nextTick); - - expect(resource.value).toEqual({ - data: "changed", - loading: false, - error: null - }); - }); - - test("can create custom storages", () => { - const useUndo = (value: T) => { - const _valueStack: T[] = []; - - // add the initial value to the stack - _valueStack.push(value); - - function undo() { - _valueStack.pop(); - } - - const customStorage = createStorage( - () => { - // Get value at the top of the stack - return _valueStack[_valueStack.length - 1]; - }, - (newValue) => { - _valueStack.push(newValue); - } - ); - - return { - ...customStorage, - undo - }; - }; - - const signal = $signal(0, { - storage: useUndo - }) as unknown as Signal & { undo: () => void }; - - expect(signal.value).toBe(0); - - signal.value = 1; - expect(signal.value).toBe(1); - - signal.value = 2; - expect(signal.value).toBe(2); - - signal.undo(); - expect(signal.value).toBe(1); - - signal.undo(); - expect(signal.value).toBe(0); - }); -}); - -describe("storing values in local storage", () => { - test("should have a default value", () => { - const signal = $signal(0, { - storage: useLocalStorage("test") - }); - expect(signal.value).toBe(0); - }); - - test("should update the value", () => { + test("contain the passed value by default", () => { const signal = $signal(0); - signal.value = 1; - expect(signal.value).toBe(1); - }); -}); - -describe("storing values in session storage", () => { - test("should have a default value", () => { - const signal = $signal(0, { - storage: useSessionStorage("test") - }); expect(signal.value).toBe(0); }); - test("should update the value", () => { + test("update their value when a new one is set", () => { const signal = $signal(0); signal.value = 1; expect(signal.value).toBe(1); }); -}); -describe("storing values in cookies", () => { - test("should have a default value", () => { - const signal = $signal(0, { - storage: useCookies("test") + test("delay changing their value when debounced", async () => { + const debouncedSignal = $signal(0, { + debounce: 100 }); - expect(signal.value).toBe(0); - }); - test("should update the value", () => { - const signal = $signal(0, { - storage: useCookies("test") - }); - signal.value = 1; - expect(signal.value).toBe(1); - }); -}); - -describe("when receiving a value from the empApi", () => { - it("should update the signal when the message is received", async () => { - function handleEvent(event?: { data: { payload: Record } }) { - return event?.data.payload.Message__c ?? ""; - } - - const signal = $signal("", { - storage: useEventBus("/event/TestChannel__e", handleEvent) - }); + debouncedSignal.value = 1; - await new Promise(process.nextTick); + expect(debouncedSignal.value).toBe(0); - expect(signal.value).toBe(""); + await new Promise(resolve => setTimeout(resolve, 100)); - await jestMockPublish("/event/TestChannel__e", { - data: { - payload: { - Message__c: "Hello World!" - } - } - }); - - await new Promise(process.nextTick); - - expect(signal.value).toBe("Hello World!"); + expect(debouncedSignal.value).toBe(1); }); }); + + diff --git a/src/lwc/signals/__tests__/track.test.ts b/src/lwc/signals/__tests__/track.test.ts new file mode 100644 index 0000000..1a2acf0 --- /dev/null +++ b/src/lwc/signals/__tests__/track.test.ts @@ -0,0 +1,161 @@ +import { $computed, $effect, $signal } from "../core"; + +describe("a tracked signal", () => { + test("recomputes when the source is an object that changes", () => { + const signal = $signal({ a: 0, b: 1 }, { track: true }); + const computed = $computed(() => signal.value.a * 2); + expect(computed.value).toBe(0); + + signal.value.a = 1; + + expect(computed.value).toBe(2); + }); + + test("recomputes when a nested property of the source object changes", () => { + const signal = $signal({ a: { b: 0 } }, { track: true }); + const computed = $computed(() => signal.value.a.b * 2); + expect(computed.value).toBe(0); + + signal.value.a.b = 1; + + expect(computed.value).toBe(2); + }); + + test("recomputes when the source is an array that gets a push", () => { + const signal = $signal([0], { track: true }); + const computed = $computed(() => signal.value.length); + expect(computed.value).toBe(1); + + signal.value.push(1); + + expect(computed.value).toBe(2); + }); + + test("recomputes when the source is an array that changes through a pop ", () => { + const signal = $signal([0], { track: true }); + const computed = $computed(() => signal.value.length); + expect(computed.value).toBe(1); + + signal.value.pop(); + + expect(computed.value).toBe(0); + }); + + test("recomputes when the source is an array that changes through a shift", () => { + const signal = $signal([0], { track: true }); + const computed = $computed(() => signal.value.length); + expect(computed.value).toBe(1); + + signal.value.shift(); + + expect(computed.value).toBe(0); + }); + + test("recomputes when the source is an array that changes through a splice", () => { + const signal = $signal([0], { track: true }); + const computed = $computed(() => signal.value.length); + expect(computed.value).toBe(1); + + signal.value.splice(0, 1); + + expect(computed.value).toBe(0); + }); + + test("recomputes when the source is an array that changes through a reverse", () => { + const signal = $signal([0, 1], { track: true }); + const computed = $computed(() => signal.value.length); + expect(computed.value).toBe(2); + + signal.value.reverse(); + + expect(computed.value).toBe(2); + }); + + test("recomputes when the source is an array that changes through a sort", () => { + const signal = $signal([1, 0], { track: true }); + const computed = $computed(() => signal.value.length); + expect(computed.value).toBe(2); + + signal.value.sort(); + + expect(computed.value).toBe(2); + }); + + test("effects when there are updates in an object", () => { + const signal = $signal({ a: 0 }, { track: true }); + let effectTracker = 0; + + $effect(() => { + effectTracker = signal.value.a; + }); + + expect(effectTracker).toBe(0); + + signal.value.a = 1; + expect(effectTracker).toBe(1); + }); + + test("effects when there are updates in an array", () => { + const signal = $signal([0], { track: true }); + let effectTracker = 0; + + $effect(() => { + effectTracker = signal.value.length; + }); + + expect(effectTracker).toBe(1); + + signal.value.push(1); + expect(effectTracker).toBe(2); + }); +}); + +describe("an untracked signal", () => { + test("does not recompute when the source is an object that gets updated", () => { + const signal = $signal({ a: 0 }); + const computed = $computed(() => signal.value.a * 2); + expect(computed.value).toBe(0); + + signal.value.a = 1; + + expect(computed.value).toBe(0); + }); + + test("does not recompute when the source is an array that gets updated", () => { + const signal = $signal([0]); + const computed = $computed(() => signal.value.length); + expect(computed.value).toBe(1); + + signal.value.push(1); + + expect(computed.value).toBe(1); + }); + + test("does not effect when there are changes to an object", () => { + const signal = $signal({ a: 0 }); + let effectTracker = 0; + + $effect(() => { + effectTracker = signal.value.a; + }); + + expect(effectTracker).toBe(0); + + signal.value.a = 1; + expect(effectTracker).toBe(0); + }); + + test("does not effect to updates in an array", () => { + const signal = $signal([0]); + let effectTracker = 0; + + $effect(() => { + effectTracker = signal.value.length; + }); + + expect(effectTracker).toBe(1); + + signal.value.push(1); + expect(effectTracker).toBe(1); + }); +}); diff --git a/src/lwc/signals/core.ts b/src/lwc/signals/core.ts index 4ca4762..bb3baaa 100644 --- a/src/lwc/signals/core.ts +++ b/src/lwc/signals/core.ts @@ -1,5 +1,6 @@ import { useInMemoryStorage, State } from "./use"; import { debounce } from "./utils"; +import { ObservableMembrane } from "./observable-membrane/observable-membrane"; type ReadOnlySignal = { readonly value: T; @@ -81,10 +82,55 @@ function $computed(fn: ComputedFunction): ReadOnlySignal { type StorageFn = (value: T) => State & { [key: string]: unknown }; type SignalOptions = { - storage: StorageFn - debounce?: number + storage: StorageFn; + debounce?: number; + track?: boolean; }; +interface TrackableState { + get(): T; + + set(value: T): void; +} + +class UntrackedState implements TrackableState { + private _value: T; + + constructor(value: T) { + this._value = value; + } + + get() { + return this._value; + } + + set(value: T) { + this._value = value; + } +} + +class TrackedState implements TrackableState { + private _value: T; + private _membrane: ObservableMembrane; + + constructor(value: T, onChangeCallback: VoidFunction) { + this._membrane = new ObservableMembrane({ + valueMutated() { + onChangeCallback(); + } + }); + this._value = this._membrane.getProxy(value); + } + + get() { + return this._value; + } + + set(value: T) { + this._value = this._membrane.getProxy(value); + } +} + /** * Creates a new signal with the provided value. A signal is a reactive * primitive that can be used to store and update values. Signals can be @@ -108,8 +154,21 @@ type SignalOptions = { * @param value The initial value of the signal * @param options Options to configure the signal */ -function $signal(value: T, options?: Partial>): Signal & Omit>, "get" | "set"> { - const _storageOption: State = options?.storage?.(value) ?? useInMemoryStorage(value); +function $signal( + value: T, + options?: Partial> +): Signal & Omit>, "get" | "set"> { + // Defaults to not tracking changes through the Observable Membrane. + // 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 _storageOption: State = + options?.storage?.(trackableState.get()) ?? + useInMemoryStorage(trackableState.get()); const subscribers: Set = new Set(); function getter() { @@ -124,7 +183,8 @@ function $signal(value: T, options?: Partial>): Signal & if (newValue === _storageOption.get()) { return; } - _storageOption.set(newValue); + trackableState.set(newValue); + _storageOption.set(trackableState.get()); notifySubscribers(); } @@ -136,25 +196,29 @@ function $signal(value: T, options?: Partial>): Signal & _storageOption.registerOnChange?.(notifySubscribers); - const debouncedSetter = debounce((newValue) => setter(newValue as T), options?.debounce ?? 0); - const returnValue: Signal & Omit>, "get" | "set"> = { - ..._storageOption, - get value() { - return getter(); - }, - set value(newValue: T) { - if (options?.debounce) { - debouncedSetter(newValue); - } else { - setter(newValue); - } - }, - readOnly: { + const debouncedSetter = debounce( + (newValue) => setter(newValue as T), + options?.debounce ?? 0 + ); + const returnValue: Signal & Omit>, "get" | "set"> = + { + ..._storageOption, get value() { return getter(); + }, + set value(newValue: T) { + if (options?.debounce) { + debouncedSetter(newValue); + } else { + setter(newValue); + } + }, + readOnly: { + get value() { + return getter(); + } } - } - }; + }; // We don't want to expose the `get` and `set` methods, so // remove before returning @@ -182,7 +246,11 @@ type UnknownArgsMap = { [key: string]: unknown }; type MutatorCallback = (value: T | null, error?: unknown) => void; -type OnMutate = (newValue: T, oldValue: T | null, mutate: MutatorCallback) => Promise | void; +type OnMutate = ( + newValue: T, + oldValue: T | null, + mutate: MutatorCallback +) => Promise | void; type FetchWhenPredicate = () => boolean; diff --git a/src/lwc/signals/observable-membrane/base-handler.ts b/src/lwc/signals/observable-membrane/base-handler.ts new file mode 100644 index 0000000..bb7e4ab --- /dev/null +++ b/src/lwc/signals/observable-membrane/base-handler.ts @@ -0,0 +1,197 @@ +/* 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, + ProxyPropertyKey, +} from './shared'; +import { ObservableMembrane } from './observable-membrane'; + +export type ShadowTarget = object; + +export abstract class BaseProxyHandler { + originalTarget: any; + membrane: ObservableMembrane; + + constructor(membrane: ObservableMembrane, value: any) { + this.originalTarget = value; + this.membrane = membrane; + } + + // Abstract utility methods + + abstract wrapValue(value: any): any; + 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 + + // 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 }, + } = this; + const value = originalTarget[key]; + 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 new file mode 100644 index 0000000..5ed17d4 --- /dev/null +++ b/src/lwc/signals/observable-membrane/main.ts @@ -0,0 +1,10 @@ +/* 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 + */ +export { ObservableMembrane } from './observable-membrane'; diff --git a/src/lwc/signals/observable-membrane/observable-membrane.ts b/src/lwc/signals/observable-membrane/observable-membrane.ts new file mode 100644 index 0000000..9610d43 --- /dev/null +++ b/src/lwc/signals/observable-membrane/observable-membrane.ts @@ -0,0 +1,133 @@ +/* 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, + getPrototypeOf, + isFunction, + registerProxy, + ProxyPropertyKey, + ObjectDotPrototype, +} from './shared'; +import { ReactiveProxyHandler } from './reactive-handler'; +import { ReadOnlyHandler } from './read-only-handler'; +import { init as initDevFormatter } from './reactive-dev-formatter'; + +initDevFormatter(); + +type ValueObservedCallback = (obj: any, key: ProxyPropertyKey) => void; +type ValueMutatedCallback = (obj: any, key: ProxyPropertyKey) => void; +type IsObservableCallback = (value: any) => boolean; + +export interface ObservableMembraneInit { + valueMutated?: ValueMutatedCallback; + valueObserved?: ValueObservedCallback; + valueIsObservable?: IsObservableCallback; + tagPropertyKey?: ProxyPropertyKey; +} + +function defaultValueIsObservable(value: any): boolean { + // 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: ValueObservedCallback = (obj: any, key: ProxyPropertyKey) => { + /* do nothing */ +}; +const defaultValueMutated: ValueMutatedCallback = (obj: any, key: ProxyPropertyKey) => { + /* do nothing */ +}; + +function createShadowTarget(value: any): any { + return isArray(value) ? [] : {}; +} + +export class ObservableMembrane { + valueMutated: ValueMutatedCallback; + valueObserved: ValueObservedCallback; + valueIsObservable: IsObservableCallback; + tagPropertyKey: ProxyPropertyKey | undefined; + + private readOnlyObjectGraph: WeakMap = new WeakMap(); + private reactiveObjectGraph: WeakMap = new WeakMap(); + + constructor(options: ObservableMembraneInit = {}) { + 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: any) { + 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: any) { + value = unwrap(value); + if (this.valueIsObservable(value)) { + return this.getReadOnlyHandler(value); + } + return value; + } + + unwrapProxy(p: any) { + return unwrap(p); + } + + private getReactiveHandler(value: any): any { + 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; + } + + private getReadOnlyHandler(value: any): any { + 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; + } +} diff --git a/src/lwc/signals/observable-membrane/reactive-dev-formatter.ts b/src/lwc/signals/observable-membrane/reactive-dev-formatter.ts new file mode 100644 index 0000000..683dc47 --- /dev/null +++ b/src/lwc/signals/observable-membrane/reactive-dev-formatter.ts @@ -0,0 +1,112 @@ +/* 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 { + ArrayPush, + ArrayConcat, + isArray, + ObjectCreate, + getPrototypeOf, + getOwnPropertyNames, + getOwnPropertySymbols, + unwrap, + ProxyPropertyKey, +} from './shared'; + +// Define globalThis since it's not current defined in by typescript. +// https://github.com/tc39/proposal-global +declare var globalThis: any; + +interface DevToolFormatter { + header: (object: any, config: any) => any; + hasBody: (object: any, config: any) => boolean | null; + body: (object: any, config: any) => any; +} + +function extract(objectOrArray: any): any { + if (isArray(objectOrArray)) { + return objectOrArray.map((item) => { + const original = unwrap(item); + if (original !== item) { + return extract(original); + } + return item; + }); + } + + const obj = ObjectCreate(getPrototypeOf(objectOrArray)); + const names = getOwnPropertyNames(objectOrArray); + return ArrayConcat.call(names, getOwnPropertySymbols(objectOrArray)).reduce( + (seed: any, key: ProxyPropertyKey) => { + const item = objectOrArray[key]; + const original = unwrap(item); + if (original !== item) { + seed[key] = extract(original); + } else { + seed[key] = item; + } + return seed; + }, + obj, + ); +} + +const formatter: DevToolFormatter = { + header: (plainOrProxy) => { + const originalTarget = unwrap(plainOrProxy); + // if originalTarget is falsy or not unwrappable, exit + if (!originalTarget || originalTarget === plainOrProxy) { + return null; + } + + const obj = extract(plainOrProxy); + return ['object', { object: obj }]; + }, + hasBody: () => { + return false; + }, + body: () => { + return null; + }, +}; + +// 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. + if (typeof globalThis !== 'undefined') { + return globalThis; + } + if (typeof self !== 'undefined') { + return self; + } + if (typeof window !== 'undefined') { + return window; + } + if (typeof global !== 'undefined') { + return global; + } + + // Gracefully degrade if not able to locate the global object + return {}; +} + +export function init() { + const global = getGlobal(); + + // Custom Formatter for Dev Tools. To enable this, open Chrome Dev Tools + // - Go to Settings, + // - Under console, select "Enable custom formatters" + // For more information, https://docs.google.com/document/d/1FTascZXT9cxfetuPRT2eXPQKXui4nWFivUnS_335T3U/preview + const devtoolsFormatters = global.devtoolsFormatters || []; + ArrayPush.call(devtoolsFormatters, formatter); + global.devtoolsFormatters = devtoolsFormatters; +} diff --git a/src/lwc/signals/observable-membrane/reactive-handler.ts b/src/lwc/signals/observable-membrane/reactive-handler.ts new file mode 100644 index 0000000..ca28281 --- /dev/null +++ b/src/lwc/signals/observable-membrane/reactive-handler.ts @@ -0,0 +1,179 @@ +/* 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 { + toString, + isArray, + unwrap, + isExtensible, + preventExtensions, + ObjectDefineProperty, + hasOwnProperty, + 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>(); +const reverseGetterMap = new WeakMap<() => any, () => any>(); +const reverseSetterMap = new WeakMap<(v: any) => void, (v: any) => void>(); + +export class ReactiveProxyHandler extends BaseProxyHandler { + wrapValue(value: any): any { + return this.membrane.getProxy(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); + reverseGetterMap.set(get, originalGet); + return get; + } + wrapSetter(originalSet: (v: any) => void): (v: any) => void { + const wrappedSetter = setterMap.get(originalSet); + if (!isUndefined(wrappedSetter)) { + return wrappedSetter; + } + const set = function (this: any, v: any) { + // invoking the original setter with the original target + originalSet.call(unwrap(this), unwrap(v)); + }; + setterMap.set(originalSet, set); + reverseSetterMap.set(set, originalSet); + return set; + } + 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 }, + } = 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: 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 new file mode 100644 index 0000000..f299419 --- /dev/null +++ b/src/lwc/signals/observable-membrane/read-only-handler.ts @@ -0,0 +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'; + +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, 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 new file mode 100644 index 0000000..8271ae2 --- /dev/null +++ b/src/lwc/signals/observable-membrane/shared.ts @@ -0,0 +1,72 @@ +/* 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 + */ +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; + +export { + ArrayPush, + ArrayConcat, + isArray, + getPrototypeOf, + ObjectCreate, + ObjectDefineProperty, + ObjectDotPrototype, + isExtensible, + getOwnPropertyDescriptor, + getOwnPropertyNames, + getOwnPropertySymbols, + preventExtensions, + hasOwnProperty, +}; + +const OtS = {}.toString; +export function toString(obj: any): string { + if (obj && obj.toString) { + return obj.toString(); + } else if (typeof obj === 'object') { + return OtS.call(obj); + } else { + return obj + ''; + } +} + +export function isUndefined(obj: any): obj is undefined { + return obj === undefined; +} + +export function isFunction(obj: any): obj is Function { + return typeof obj === 'function'; +} + +const proxyToValueMap: WeakMap = new WeakMap(); + +export function registerProxy(proxy: object, value: any) { + proxyToValueMap.set(proxy, value); +} + +export const unwrap = (replicaOrAny: any): any => proxyToValueMap.get(replicaOrAny) || replicaOrAny; + +// In the specification for Proxy, the keys are defined not as PropertyKeys (i.e. `string | symbol | number`) +// but as `string | symbol`. See: https://github.com/microsoft/TypeScript/pull/35594 +export type ProxyPropertyKey = string | symbol; diff --git a/src/lwc/signals/utils.ts b/src/lwc/signals/utils.ts index c8aad61..46be384 100644 --- a/src/lwc/signals/utils.ts +++ b/src/lwc/signals/utils.ts @@ -1,9 +1,9 @@ export function debounce unknown>(func: F, delay: number): (...args: Parameters) => void { - let debounceTimer: NodeJS.Timeout | null = null; + let debounceTimer: number | null = null; return (...args: Parameters) => { if (debounceTimer) { clearTimeout(debounceTimer); } - debounceTimer = setTimeout(() => func(...args), delay); + debounceTimer = window.setTimeout(() => func(...args), delay); }; }