-
-
- Checkout
-
-
+
+
+
+ Checkout
+
+
-
+
-
\ 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);
};
}