diff --git a/README.md b/README.md index 4ee01b7..dd46336 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ In this example, the test-identifier string will appear as part of the console.e ### Custom Error Handlers -Both computed and effect signals can receive a custom `errorHandler` property, +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 @@ -258,10 +258,11 @@ that allows developers to completely override the default functionality that log For `$effect` handlers, you can pass a function with the following shape: ```typescript -(error: any) => void +(error: any, options: { identifier: string | symbol }) => void ``` -The thrown error will be passed as an argument, and nothing should be returned. +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: @@ -275,7 +276,7 @@ $effect( throw new Error("test"); }, { - errorHandler: customErrorHandlerFn + onError: customErrorHandlerFn } ); ``` @@ -285,16 +286,22 @@ $effect( For `$computed` handlers, you can pass a function with the following shape: ```typescript -(error: unknown) => T | undefined; +(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) { +function customErrorHandlerFn(error, _previousValue, _options) { // custom logic or logging or rethrowing here return "fallback value"; } @@ -304,7 +311,7 @@ $computed( throw new Error("test"); }, { - errorHandler: customErrorHandlerFn + onError: customErrorHandlerFn } ); ``` diff --git a/force-app/lwc/signals/core.js b/force-app/lwc/signals/core.js index 763efc4..b10b0f4 100644 --- a/force-app/lwc/signals/core.js +++ b/force-app/lwc/signals/core.js @@ -10,9 +10,9 @@ const UNSET = Symbol("UNSET"); const COMPUTING = Symbol("COMPUTING"); const ERRORED = Symbol("ERRORED"); const READY = Symbol("READY"); -const defaultEffectProps = { +const defaultEffectOptions = { _fromComputed: false, - identifier: null + identifier: Symbol() }; /** * Creates a new effect that will be executed immediately and whenever @@ -32,10 +32,10 @@ const defaultEffectProps = { * ``` * * @param fn The function to execute - * @param props Options to configure the effect + * @param options Options to configure the effect */ -function $effect(fn, props) { - const _props = { ...defaultEffectProps, ...props }; +function $effect(fn, options) { + const _optionsWithDefaults = { ...defaultEffectOptions, ...options }; const effectNode = { error: null, state: UNSET @@ -53,23 +53,30 @@ function $effect(fn, props) { } catch (error) { effectNode.state = ERRORED; effectNode.error = error; - _props.errorHandler - ? _props.errorHandler(error) - : handleEffectError(error, _props); + _optionsWithDefaults.onError + ? _optionsWithDefaults.onError(error, _optionsWithDefaults) + : handleEffectError(error, _optionsWithDefaults); } finally { context.pop(); } }; execute(); + return { + identifier: _optionsWithDefaults.identifier + }; } -function handleEffectError(error, props) { - const source = - (props._fromComputed ? "Computed" : "Effect") + - (props.identifier ? ` (${props.identifier})` : ""); - const errorMessage = `An error occurred in a ${source} function`; - console.error(errorMessage, error); +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 @@ -84,21 +91,25 @@ function handleEffectError(error, props) { * ``` * * @param fn The function that returns the computed value. - * @param props Options to configure the computed value. + * @param options Options to configure the computed value. */ -function $computed(fn, props) { +function $computed(fn, options) { + const _optionsWithDefaults = { ...defaultComputedOptions, ...options }; const computedSignal = $signal(undefined, { track: true }); $effect( () => { - if (props?.errorHandler) { - // If this computed has a custom errorHandler, then error + 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) { - computedSignal.value = props.errorHandler(error); + const previousValue = computedSignal.peek(); + computedSignal.value = options.onError(error, previousValue, { + identifier: _optionsWithDefaults.identifier + }); } } else { // Otherwise, the error handling is done in the $effect @@ -107,10 +118,12 @@ function $computed(fn, props) { }, { _fromComputed: true, - identifier: props?.identifier ?? null + identifier: _optionsWithDefaults.identifier } ); - return computedSignal.readOnly; + const returnValue = computedSignal.readOnly; + returnValue.identifier = _optionsWithDefaults.identifier; + return returnValue; } class UntrackedState { constructor(value) { @@ -225,6 +238,9 @@ function $signal(value, options) { get value() { return getter(); } + }, + peek() { + return _storageOption.get(); } }; delete returnValue.get; diff --git a/src/lwc/signals/__tests__/computed.test.ts b/src/lwc/signals/__tests__/computed.test.ts index 102104c..202758c 100644 --- a/src/lwc/signals/__tests__/computed.test.ts +++ b/src/lwc/signals/__tests__/computed.test.ts @@ -86,6 +86,11 @@ describe("computed values", () => { 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(() => {}); @@ -109,7 +114,7 @@ describe("computed values", () => { $computed(() => { throw new Error("test"); }, { - errorHandler: customErrorHandlerFn + onError: customErrorHandlerFn }); expect(customErrorHandlerFn).toHaveBeenCalled(); @@ -123,7 +128,7 @@ describe("computed values", () => { const computed = $computed(() => { throw new Error("test"); }, { - errorHandler: customErrorHandlerFn + onError: customErrorHandlerFn }); expect(computed.value).toBe("fallback"); @@ -142,7 +147,7 @@ describe("computed values", () => { return signal.value; }, { - errorHandler: customErrorHandlerFn + onError: customErrorHandlerFn }); expect(computed.value).toBe(0); @@ -155,4 +160,35 @@ describe("computed values", () => { 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 ebd66e2..f934951 100644 --- a/src/lwc/signals/__tests__/effect.test.ts +++ b/src/lwc/signals/__tests__/effect.test.ts @@ -40,6 +40,11 @@ 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 { @@ -52,17 +57,83 @@ describe("effects", () => { 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"); }, { - errorHandler: customErrorHandlerFn + 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(() => { diff --git a/src/lwc/signals/core.ts b/src/lwc/signals/core.ts index e69d63c..96e3b1e 100644 --- a/src/lwc/signals/core.ts +++ b/src/lwc/signals/core.ts @@ -15,6 +15,10 @@ export type Signal = { peek(): T; }; +type Effect = { + identifier: string | symbol; +}; + const context: VoidFunction[] = []; function _getCurrentObserver(): VoidFunction | undefined { @@ -31,15 +35,15 @@ interface EffectNode { state: symbol; } -type EffectProps = { +type EffectOptions = { _fromComputed: boolean; - identifier: string | null; - errorHandler?: (error: unknown) => void; + identifier: string | symbol; + onError?: (error: unknown, options: EffectOptions) => void; }; -const defaultEffectProps: EffectProps = { +const defaultEffectOptions: EffectOptions = { _fromComputed: false, - identifier: null + identifier: Symbol() }; /** @@ -60,10 +64,10 @@ const defaultEffectProps: EffectProps = { * ``` * * @param fn The function to execute - * @param props Options to configure the effect + * @param options Options to configure the effect */ -function $effect(fn: VoidFunction, props?: Partial): void { - const _props = { ...defaultEffectProps, ...props }; +function $effect(fn: VoidFunction, options?: Partial): Effect { + const _optionsWithDefaults = { ...defaultEffectOptions, ...options }; const effectNode: EffectNode = { error: null, state: UNSET @@ -83,30 +87,48 @@ function $effect(fn: VoidFunction, props?: Partial): void { } catch (error) { effectNode.state = ERRORED; effectNode.error = error; - _props.errorHandler - ? _props.errorHandler(error) - : handleEffectError(error, _props); + _optionsWithDefaults.onError + ? _optionsWithDefaults.onError(error, _optionsWithDefaults) + : handleEffectError(error, _optionsWithDefaults); } finally { context.pop(); } }; execute(); + + return { + identifier: _optionsWithDefaults.identifier + }; } -function handleEffectError(error: unknown, props: EffectProps) { - const source = - (props._fromComputed ? "Computed" : "Effect") + - (props.identifier ? ` (${props.identifier})` : ""); - const errorMessage = `An error occurred in a ${source} function`; - console.error(errorMessage, error); +function handleEffectError(error: unknown, options: EffectOptions) { + 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; } type ComputedFunction = () => T; -type ComputedProps = { - identifier: string | null; - errorHandler?: (error: unknown, previousValue: T | undefined) => T | undefined; +type ComputedOptions = { + identifier: string | symbol; + onError?: ( + error: unknown, + previousValue: T | undefined, + options: { identifier: string | symbol } + ) => T | undefined; +}; + +const defaultComputedOptions: ComputedOptions = { + identifier: Symbol() +}; + +type Computed = ReadOnlySignal & { + identifier: string | symbol; }; /** @@ -123,25 +145,28 @@ type ComputedProps = { * ``` * * @param fn The function that returns the computed value. - * @param props Options to configure the computed value. + * @param options Options to configure the computed value. */ function $computed( fn: ComputedFunction, - props?: Partial> -): ReadOnlySignal { + options?: Partial> +): Computed { + const _optionsWithDefaults = { ...defaultComputedOptions, ...options }; const computedSignal: Signal = $signal(undefined, { track: true }); $effect( () => { - if (props?.errorHandler) { - // If this computed has a custom errorHandler, then error + 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 = props.errorHandler(error, previousValue); + computedSignal.value = options.onError(error, previousValue, { + identifier: _optionsWithDefaults.identifier + }); } } else { // Otherwise, the error handling is done in the $effect @@ -150,10 +175,13 @@ function $computed( }, { _fromComputed: true, - identifier: props?.identifier ?? null + identifier: _optionsWithDefaults.identifier } ); - return computedSignal.readOnly as ReadOnlySignal; + + const returnValue = computedSignal.readOnly as Computed; + returnValue.identifier = _optionsWithDefaults.identifier; + return returnValue; } type StorageFn = (value: T) => State & { [key: string]: unknown };