diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 628ee1c..7b61fa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: ["main"] + branches: ["main", "develop"] pull_request: - branches: ["main"] + branches: ["main", "develop"] workflow_dispatch: jobs: diff --git a/README.md b/README.md index c7aaffa..dd46336 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,8 @@ A simple yet powerful reactive state management solution for Lightning Web Compo ![Version](https://img.shields.io/badge/version-1.1.1-blue) Inspired by the Signals technology behind SolidJs, Preact, Svelte 5 Runes and the Vue 3 Composition API, LWC Signals is -a -reactive signals for Lightning Web Components that allows you to create reactive data signals -that can be used to share state between components. +a reactive signals implementation for Lightning Web Components. +It allows you to create reactive data signals that can be used to share up-to-date state between components. It features: @@ -161,7 +160,7 @@ export default class Display extends LightningElement {
-### Stacking computed values +#### Stacking computed values You can also stack computed values to create more complex reactive values that derive from each other @@ -176,6 +175,147 @@ export const counterPlusTwo = $computed(() => counterPlusOne.value + 1); Because `$computed` values return a signal, you can use them as you would use any other signal. +### `$effect` + +You can also use the `$effect` function to create a side effect that depends on a signal. + +Let's say you want to keep a log of the changes in the `counter` signal. + +```javascript +import { $signal, $effect } from "c/signals"; + +export const counter = $signal(0); + +$effect(() => console.log(counter.value)); +``` + +> ❗ DO NOT use `$effect` to update the signal value, as it will create an infinite loop. + +## Peeking at the signal value + +If the rare case that you have an effect that needs to read of a signal without subscribing to it, you can +use the signal's `peek` function to read the value. + +```javascript +import { $signal, $effect } from "c/signals"; + +const counter = $signal(0); + +$effect(() => console.log(counter.peek())); +``` + +This can be useful when you need to update the value of a signal based on its current value, but you want +to avoid causing a circular dependency. + +```javascript +const counter = $signal(0); +$effect(() => { + // Without peeking, this kind of operation would cause a circular dependency. + counter.value = counter.peek() + 1; +}); +``` + +Note that you should use this feature sparingly, as it can lead to bugs that are hard to track down. +The preferred way of reading a signal is through the `signal.value`. + +## Error Handling + +When unhandled errors occur in a `computed` or `effect`, by default, the error will be logged to the console through +a `console.error` call, and then the error will be rethrown. + +If you wish to know which `computed` or `effect` caused the error, you can pass a second argument to the `computed` or +`effect` with a unique identifier. + +```javascript +$computed( + () => { + signal.value; + throw new Error("error"); + }, + { identifier: "test-identifier" } +); + +$effect( + () => { + signal.value; + throw new Error("error"); + }, + { identifier: "test-identifier" } +); +``` + +This value will be used only for debugging purposes, and does not affect the functionality otherwise. + +In this example, the test-identifier string will appear as part of the console.error message. + +### Custom Error Handlers + +Both computed and effect signals can receive a custom `onError` property, +that allows developers to completely override the default functionality that logs and rethrows the error. + +#### Effect handlers + +For `$effect` handlers, you can pass a function with the following shape: + +```typescript +(error: any, options: { identifier: string | symbol }) => void +``` + +The function will receive the thrown error as the first argument, and an object with the identifier as the second. +It should not return anything. + +Example: + +```javascript +function customErrorHandlerFn(error) { + // custom logic or logging or rethrowing here +} + +$effect( + () => { + throw new Error("test"); + }, + { + onError: customErrorHandlerFn + } +); +``` + +#### Computed handlers + +For `$computed` handlers, you can pass a function with the following shape: + +```typescript +(error: unknown, previousValue: T, options: { identifier: string | symbol }) => + T | undefined; +``` + +Where you can return nothing, or a value of type `T`, which should be of the same type as the computed value itself. +This allows you to provide a "fallback" value, that the computed value will receive in case of errors. + +As a second argument, you will receive the previous value of the computed signal, which can be useful to provide a +fallback value based on the previous value. + +The third argument is an object with the received identifier. + +Example + +```javascript +function customErrorHandlerFn(error, _previousValue, _options) { + // custom logic or logging or rethrowing here + return "fallback value"; +} + +$computed( + () => { + throw new Error("test"); + }, + { + onError: customErrorHandlerFn + } +); +``` + ## Tracking objects and arrays By default, for a signal to be reactive it needs to be reassigned. This can be cumbersome when dealing with objects @@ -200,8 +340,8 @@ console.log(computedFromObj.value); // 4 ## Reacting to multiple signals -You can also use multiple signals in a single `computed` and react to changes in any of them. -This gives you the ability to create complex reactive values that depend on multiple data sources +You can also use multiple signals in a single `computed` or `effect` and react to changes in any of them. +This allows you to create complex reactive values that depend on multiple data sources without having to track each one independently. > 👀 You can find the full working code for the following example in the `examples` @@ -296,22 +436,6 @@ export default class BusinessCard extends LightningElement { > ❗ Notice that we are using a property instead of a getter in the `$computed` callback function, because > we need to reassign the value to `this.contactInfo` to trigger the reactivity, as it is a complex object. -### `$effect` - -You can also use the `$effect` function to create a side effect that depends on a signal. - -Let's say you want to keep a log of the changes in the `counter` signal. - -```javascript -import { $signal, $effect } from "c/signals"; - -export const counter = $signal(0); - -$effect(() => console.log(counter.value)); -``` - -> ❗ DO NOT use `$effect` to update the signal value, as it will create an infinite loop. - ## Communicating with Apex data and other asynchronous operations You can also use the signals framework to communicate with Apex data and other asynchronous operations. @@ -817,7 +941,8 @@ The following storage helpers are available by default: if using a platform event, this will contain the fields of the platform event. - The `options` (optional) parameter is an object that can contain the following properties (all of them optional): - - `replayId` The replay ID to start from, defaults to -1. When -2 is passed, it will replay from the last saved event. + - `replayId` The replay ID to start from, defaults to -1. When -2 is passed, it will replay from the last saved + event. - `onSubscribe` A callback function called when the subscription is successful. - `onError` A callback function called when an error response is received from the server for handshake, connect, subscribe, and unsubscribe meta channels. diff --git a/examples/demo-signals/lwc/demoSignals/demoSignals.js b/examples/demo-signals/lwc/demoSignals/demoSignals.js index 4917cf7..bbb2ff0 100644 --- a/examples/demo-signals/lwc/demoSignals/demoSignals.js +++ b/examples/demo-signals/lwc/demoSignals/demoSignals.js @@ -3,3 +3,4 @@ export * from "./contact-info"; export * from "./apex-fetcher"; export * from "./shopping-cart"; export * from "./chat-data-source"; +export * from "./error-handler"; diff --git a/examples/demo-signals/lwc/demoSignals/error-handler.js b/examples/demo-signals/lwc/demoSignals/error-handler.js new file mode 100644 index 0000000..793af96 --- /dev/null +++ b/examples/demo-signals/lwc/demoSignals/error-handler.js @@ -0,0 +1,31 @@ +import { $signal, $computed, $effect } from "c/signals"; + +const anySignal = $signal(0); + +$computed( + () => { + anySignal.value; + throw new Error("An error occurred during a computation"); + }, + { + onError: (error /*_previousValue*/) => { + console.error("error thrown from computed", error); + // Allows for a fallback value to be returned when an error occurs. + return 0; + + // The previous value can also be returned to keep the last known value. + // return previousValue; + } + } +); + +$effect( + () => { + throw new Error("An error occurred during an effect"); + }, + { + onError: (error) => { + console.error("error thrown from effect", error); + } + } +); diff --git a/force-app/lwc/signals/core.js b/force-app/lwc/signals/core.js index ec9c82e..1be69c4 100644 --- a/force-app/lwc/signals/core.js +++ b/force-app/lwc/signals/core.js @@ -10,6 +10,10 @@ const UNSET = Symbol("UNSET"); const COMPUTING = Symbol("COMPUTING"); const ERRORED = Symbol("ERRORED"); const READY = Symbol("READY"); +const defaultEffectOptions = { + _fromComputed: false, + identifier: Symbol() +}; /** * Creates a new effect that will be executed immediately and whenever * any of the signals it reads from change. @@ -28,8 +32,10 @@ const READY = Symbol("READY"); * ``` * * @param fn The function to execute + * @param options Options to configure the effect */ -function $effect(fn) { +function $effect(fn, options) { + const _optionsWithDefaults = { ...defaultEffectOptions, ...options }; const effectNode = { error: null, state: UNSET @@ -44,18 +50,33 @@ function $effect(fn) { fn(); effectNode.error = null; effectNode.state = READY; + } catch (error) { + effectNode.state = ERRORED; + effectNode.error = error; + _optionsWithDefaults.onError + ? _optionsWithDefaults.onError(error, _optionsWithDefaults) + : handleEffectError(error, _optionsWithDefaults); } finally { context.pop(); } }; execute(); + return { + identifier: _optionsWithDefaults.identifier + }; } -function computedGetter(node) { - if (node.state === ERRORED) { - throw node.error; - } - return node.signal.readOnly; +function handleEffectError(error, options) { + const errorTemplate = ` + LWC Signals: An error occurred in a reactive function \n + Type: ${options._fromComputed ? "Computed" : "Effect"} \n + Identifier: ${options.identifier.toString()} + `.trim(); + console.error(errorTemplate, error); + throw error; } +const defaultComputedOptions = { + identifier: Symbol() +}; /** * Creates a new computed value that will be updated whenever the signals * it reads from change. Returns a read-only signal that contains the @@ -70,28 +91,39 @@ function computedGetter(node) { * ``` * * @param fn The function that returns the computed value. + * @param options Options to configure the computed value. */ -function $computed(fn) { - const computedNode = { - signal: $signal(undefined), - error: null, - state: UNSET - }; - $effect(() => { - if (computedNode.state === COMPUTING) { - throw new Error("Circular dependency detected"); - } - try { - computedNode.state = COMPUTING; - computedNode.signal.value = fn(); - computedNode.error = null; - computedNode.state = READY; - } catch (error) { - computedNode.state = ERRORED; - computedNode.error = error; - } +function $computed(fn, options) { + const _optionsWithDefaults = { ...defaultComputedOptions, ...options }; + const computedSignal = $signal(undefined, { + track: true }); - return computedGetter(computedNode); + $effect( + () => { + if (options?.onError) { + // If this computed has a custom error handler, then the + // handling occurs in the computed function itself. + try { + computedSignal.value = fn(); + } catch (error) { + const previousValue = computedSignal.peek(); + computedSignal.value = options.onError(error, previousValue, { + identifier: _optionsWithDefaults.identifier + }); + } + } else { + // Otherwise, the error handling is done in the $effect + computedSignal.value = fn(); + } + }, + { + _fromComputed: true, + identifier: _optionsWithDefaults.identifier + } + ); + const returnValue = computedSignal.readOnly; + returnValue.identifier = _optionsWithDefaults.identifier; + return returnValue; } class UntrackedState { constructor(value) { @@ -103,6 +135,9 @@ class UntrackedState { set(value) { this._value = value; } + forceUpdate() { + return false; + } } class TrackedState { constructor(value, onChangeCallback) { @@ -119,6 +154,9 @@ class TrackedState { set(value) { this._value = this._membrane.getProxy(value); } + forceUpdate() { + return true; + } } /** * Creates a new signal with the provided value. A signal is a reactive @@ -163,7 +201,10 @@ function $signal(value, options) { return _storageOption.get(); } function setter(newValue) { - if (isEqual(newValue, _storageOption.get())) { + if ( + !trackableState.forceUpdate() && + isEqual(newValue, _storageOption.get()) + ) { return; } trackableState.set(newValue); @@ -192,16 +233,20 @@ function $signal(value, options) { setter(newValue); } }, + brand: Symbol.for("lwc-signals"), readOnly: { get value() { return getter(); } + }, + peek() { + return _storageOption.get(); } }; - // We don't want to expose the `get` and `set` methods, so - // remove before returning delete returnValue.get; delete returnValue.set; + delete returnValue.registerOnChange; + delete returnValue.unsubscribe; return returnValue; } function $resource(fn, source, options) { @@ -284,4 +329,7 @@ function $resource(fn, source, options) { } }; } -export { $signal, $effect, $computed, $resource }; +function isASignal(anything) { + return !!anything && anything.brand === Symbol.for("lwc-signals"); +} +export { $signal, $effect, $computed, $resource, isASignal }; diff --git a/package-lock.json b/package-lock.json index 9c1eb6b..a69f4f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@salesforce/eslint-plugin-lightning": "^1.0.0", "@tailwindcss/forms": "^0.5.7", "@types/jest": "^29.5.12", + "@types/sinon": "^17.0.3", "eslint": "^8.57.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jest": "^27.6.0", @@ -26,7 +27,7 @@ "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.1.0", "prettier": "^3.1.0", - "prettier-plugin-apex": "^2.0.1", + "sinon": "^19.0.2", "tailwindcss": "^3.4.3", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", @@ -707,21 +708,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "dev": true - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dev": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1903,27 +1889,6 @@ "eslint": "^7 || ^8" } }, - "node_modules/@sideway/address": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", - "dev": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "dev": true - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "dev": true - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1948,6 +1913,32 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true + }, "node_modules/@tailwindcss/forms": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz", @@ -2115,6 +2106,21 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "node_modules/@types/sinon": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", + "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2731,17 +2737,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axios": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", - "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", - "dev": true, - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -4823,26 +4818,6 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -7432,19 +7407,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/joi": { - "version": "17.13.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.1.tgz", - "integrity": "sha512-vaBlIKCyo4FCUtCm7Eu4QZd/q02bWcxfUO6YSXAZOWF6gzcLBeba8kwotUdYJjDLW8Cz8RywsSOqiNJZW0mNvg==", - "dev": true, - "dependencies": { - "@hapi/hoek": "^9.3.0", - "@hapi/topo": "^5.1.0", - "@sideway/address": "^4.1.5", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7556,6 +7518,12 @@ "node": ">=6" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7816,10 +7784,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", "dev": true }, "node_modules/lodash.memoize": { @@ -8127,6 +8095,28 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -8449,6 +8439,15 @@ "node": "14 || >=16.14" } }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -8740,26 +8739,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prettier-plugin-apex": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/prettier-plugin-apex/-/prettier-plugin-apex-2.1.4.tgz", - "integrity": "sha512-kGImHH2s+RsPtAXwbh5VmqqSTYhts626Zle2ryeUKJ4VY+vDyOQ53ppWOzFPA1XGdRpthh++WliD0ZVP1kdReA==", - "dev": true, - "dependencies": { - "jest-docblock": "^29.0.0", - "wait-on": "^7.2.0" - }, - "bin": { - "apex-ast-serializer": "vendor/apex-ast-serializer/bin/apex-ast-serializer", - "apex-ast-serializer-http": "vendor/apex-ast-serializer/bin/apex-ast-serializer-http", - "install-apex-executables": "dist/bin/install-apex-executables.js", - "start-apex-server": "dist/bin/start-apex-server.js", - "stop-apex-server": "dist/bin/stop-apex-server.js" - }, - "peerDependencies": { - "prettier": "^3.0.0" - } - }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -8799,12 +8778,6 @@ "node": ">= 6" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true - }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -9054,15 +9027,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", @@ -9208,6 +9172,63 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/sinon": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -9907,12 +9928,6 @@ "node": ">=4" } }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true - }, "node_modules/tsutils": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", @@ -10405,25 +10420,6 @@ "node": ">=14" } }, - "node_modules/wait-on": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", - "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", - "dev": true, - "dependencies": { - "axios": "^1.6.1", - "joi": "^17.11.0", - "lodash": "^4.17.21", - "minimist": "^1.2.8", - "rxjs": "^7.8.1" - }, - "bin": { - "wait-on": "bin/wait-on" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index 3f5d8e7..23a72af 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@salesforce/eslint-config-lwc": "^3.2.3", "@salesforce/eslint-plugin-aura": "^2.0.0", "@salesforce/eslint-plugin-lightning": "^1.0.0", + "@tailwindcss/forms": "^0.5.7", "@types/jest": "^29.5.12", "eslint": "^8.57.0", "eslint-plugin-import": "^2.25.4", @@ -33,12 +34,11 @@ "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.1.0", "prettier": "^3.1.0", + "tailwindcss": "^3.4.3", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "typescript": "^5.4.5", - "typescript-eslint": "^7.9.0", - "tailwindcss": "^3.4.3", - "@tailwindcss/forms": "^0.5.7" + "typescript-eslint": "^7.9.0" }, "lint-staged": { "**/*.{cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}": [ diff --git a/src/lwc/signals/__tests__/computed.test.ts b/src/lwc/signals/__tests__/computed.test.ts index 4666178..202758c 100644 --- a/src/lwc/signals/__tests__/computed.test.ts +++ b/src/lwc/signals/__tests__/computed.test.ts @@ -1,4 +1,4 @@ -import { $computed, $signal } from "../core"; +import { $computed, $effect, $signal } from "../core"; describe("computed values", () => { test("can be created from a source signal", () => { @@ -46,4 +46,149 @@ describe("computed values", () => { expect(computed.value).toBe(2); expect(anotherComputed.value).toBe(4); }); + + test("computed objects that return the same value as a tracked signal recomputes", () => { + const signal = $signal({ a: 0, b: 0 }, { track: true }); + const computed = $computed(() => signal.value); + const spy = jest.fn(() => computed.value); + $effect(spy); + spy.mockReset(); + + signal.value.a = 1; + expect(spy).toHaveBeenCalled(); + }); + + test("throw an error when a circular dependency is detected", () => { + console.error = jest.fn(); + expect(() => { + const signal = $signal(0); + $computed(() => { + signal.value = signal.value++; + return signal.value; + }); + }).toThrow(); + }); + + test("console errors when a computed throws an error", () => { + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); + + try { + const signal = $signal(0); + $computed(() => { + signal.value; + throw new Error("error"); + }); + signal.value = 1; + } catch (e) { + expect(spy).toHaveBeenCalled(); + } + + spy.mockRestore(); + }); + + test("have a default identifier", () => { + const computed = $computed(() => {}); + expect(computed.identifier).toBeDefined(); + }); + + test("console errors with an identifier when one was provided", () => { + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); + + try { + const signal = $signal(0); + $computed(() => { + signal.value; + throw new Error("error"); + }, { identifier: "test-identifier" }); + signal.value = 1; + } catch (e) { + expect(spy).toHaveBeenCalledWith(expect.stringContaining("test-identifier"), expect.any(Error)); + } + + spy.mockRestore(); + }); + + test("allow for errors to be handled through a custom function", () => { + const customErrorHandlerFn = jest.fn() as (error: unknown) => void; + + $computed(() => { + throw new Error("test"); + }, { + onError: customErrorHandlerFn + }); + + expect(customErrorHandlerFn).toHaveBeenCalled(); + }); + + test("allow for errors to be handled through a custom function and return a fallback value", () => { + function customErrorHandlerFn() { + return "fallback"; + } + + const computed = $computed(() => { + throw new Error("test"); + }, { + onError: customErrorHandlerFn + }); + + expect(computed.value).toBe("fallback"); + }); + + test("allows for custom error handlers to return the previous value", () => { + const signal = $signal(0); + function customErrorHandlerFn(_error: unknown, previousValue: number | undefined) { + return previousValue; + } + + const computed = $computed(() => { + if (signal.value === 2) { + throw new Error("test"); + } + + return signal.value; + }, { + onError: customErrorHandlerFn + }); + + expect(computed.value).toBe(0); + + signal.value = 1; + + expect(computed.value).toBe(1) + + signal.value = 2; + + expect(computed.value).toBe(1); + }); + + test("allows for custom error handlers to have access to the identifier", () => { + const signal = $signal(0); + const identifier = "test-identifier"; + function customErrorHandlerFn(_error: unknown, _previousValue: number | undefined, options: { identifier: string | symbol }) { + return options.identifier; + } + + const computed = $computed(() => { + if (signal.value === 2) { + throw new Error("test"); + } + + return signal.value; + }, { + // @ts-expect-error This is just for testing purposes, we are overriding the return type of the function + // which usually we should not do. + onError: customErrorHandlerFn, + identifier + }); + + expect(computed.value).toBe(0); + + signal.value = 1; + + expect(computed.value).toBe(1) + + signal.value = 2; + + expect(computed.value).toBe(identifier); + }); }); diff --git a/src/lwc/signals/__tests__/effect.test.ts b/src/lwc/signals/__tests__/effect.test.ts index 5476483..f934951 100644 --- a/src/lwc/signals/__tests__/effect.test.ts +++ b/src/lwc/signals/__tests__/effect.test.ts @@ -1,21 +1,37 @@ import { $signal, $effect } from "../core"; describe("effects", () => { + test("react to the callback immediately", () => { + const signal = $signal(0); + const spy = jest.fn(() => signal.value); + $effect(spy); + expect(spy).toHaveBeenCalled(); + }); + test("react to updates in a signal", () => { const signal = $signal(0); - let effectTracker = 0; + const spy = jest.fn(() => signal.value); + $effect(spy); + spy.mockReset(); - $effect(() => { - effectTracker = signal.value; - }); + signal.value = 1; + expect(spy).toHaveBeenCalled(); + }); - expect(effectTracker).toBe(0); + test("react to updates in multiple signals", () => { + const a = $signal(0); + const b = $signal(0); + const spy = jest.fn(() => a.value + b.value); + $effect(spy); + spy.mockReset(); - signal.value = 1; - expect(effectTracker).toBe(1); + a.value = 1; + b.value = 1; + expect(spy).toHaveBeenCalledTimes(2); }); test("throw an error when a circular dependency is detected", () => { + console.error = jest.fn(); expect(() => { const signal = $signal(0); $effect(() => { @@ -23,4 +39,108 @@ describe("effects", () => { }); }).toThrow(); }); + + test("return an object with an identifier", () => { + const effect = $effect(() => {}); + expect(effect.identifier).toBeDefined(); + }); + + test("console errors when an effect throws an error", () => { + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); + try { + $effect(() => { + throw new Error("test"); + }); + } catch (error) { + expect(spy).toHaveBeenCalled(); + } + spy.mockRestore(); + }); + + test("console errors with the default identifier", () => { + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const signal = $signal(0); + const effect = $effect(() => { + if (signal.value === 1) { + throw new Error("test"); + } + }); + + try { + signal.value = 1; + } catch (e) { + expect(spy).toHaveBeenCalledWith(expect.stringContaining(effect.identifier.toString()), expect.any(Error)); + } + + spy.mockRestore(); + }); + + test("allow for the identifier to be overridden", () => { + const signal = $signal(0); + const effect = $effect(() => { + if (signal.value === 1) { + throw new Error("test"); + } + }, { + identifier: "test-identifier" + }); + + expect(effect.identifier).toBe("test-identifier"); + }); + + test("console errors with a custom identifier if provided", () => { + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const signal = $signal(0); + $effect(() => { + if (signal.value === 1) { + throw new Error("test"); + } + }, { + identifier: "test-identifier" + }); + + try { + signal.value = 1; + } catch (e) { + expect(spy).toHaveBeenCalledWith(expect.stringContaining("test-identifier"), expect.any(Error)); + } + + spy.mockRestore(); + }); + + test("allow for errors to be handled through a custom function", () => { + const customErrorHandlerFn = jest.fn(); + $effect(() => { + throw new Error("test"); + }, { + onError: customErrorHandlerFn + }); + + expect(customErrorHandlerFn).toHaveBeenCalled(); + }); + + test("give access to the effect identifier in the onError handler", () => { + function customErrorHandler(_error: unknown, options: { identifier: string | symbol }) { + expect(options.identifier).toBe("test-identifier"); + } + + $effect(() => { + throw new Error("test"); + }, { + identifier: "test-identifier", + onError: customErrorHandler + }); + }); + + test("can change and read a signal value without causing a cycle by peeking at it", () => { + const counter = $signal(0); + $effect(() => { + // Without peeking, this kind of operation would cause a circular dependency. + counter.value = counter.peek() + 1; + }); + + expect(counter.value).toBe(1); + }); }); diff --git a/src/lwc/signals/__tests__/signal-identity.test.ts b/src/lwc/signals/__tests__/signal-identity.test.ts new file mode 100644 index 0000000..10bf381 --- /dev/null +++ b/src/lwc/signals/__tests__/signal-identity.test.ts @@ -0,0 +1,38 @@ +import { $signal, isSignal } from "../core"; + +describe("isASignal", () => { + test("checks that a value is a signal", () => { + const signal = $signal(0); + expect(isSignal(signal)).toBe(true); + }); + + test("checks that a computed is a signal", () => { + const signal = $signal(0); + const computed = $signal(() => signal.value); + expect(isSignal(computed)).toBe(true); + }); + + test("checks that a value is not a signal", () => { + expect(isSignal(0)).toBe(false); + }); + + test("checks that a function is not a signal", () => { + expect(isSignal(() => {})).toBe(false); + }); + + test("checks that an object is not a signal", () => { + expect(isSignal({})).toBe(false); + }); + + test("checks that an array is not a signal", () => { + expect(isSignal([])).toBe(false); + }); + + test("checks that undefined is not a signal", () => { + expect(isSignal(undefined)).toBe(false); + }); + + test("checks that null is not a signal", () => { + expect(isSignal(null)).toBe(false); + }); +}); diff --git a/src/lwc/signals/__tests__/signals.test.ts b/src/lwc/signals/__tests__/signals.test.ts index e06a1fa..7ecb214 100644 --- a/src/lwc/signals/__tests__/signals.test.ts +++ b/src/lwc/signals/__tests__/signals.test.ts @@ -25,6 +25,19 @@ describe("signals", () => { expect(debouncedSignal.value).toBe(1); }); + + test("should be identified with a symbol", () => { + const signal = $signal(0); + expect(signal.brand).toBe(Symbol.for("lwc-signals")); + }); + + test("allow for peeking the value without triggering a reactivity", () => { + const signal = $signal(0); + const spy = jest.fn(() => signal.value); + const value = signal.peek(); + expect(spy).not.toHaveBeenCalled(); + expect(value).toBe(0); + }); }); diff --git a/src/lwc/signals/core.ts b/src/lwc/signals/core.ts index 0a01b66..3cdead9 100644 --- a/src/lwc/signals/core.ts +++ b/src/lwc/signals/core.ts @@ -11,6 +11,12 @@ export type Signal