Skip to content

Commit

Permalink
feat: Effects and computed have a default identifier.
Browse files Browse the repository at this point in the history
  • Loading branch information
cesarParra committed Dec 6, 2024
1 parent 2d09e5a commit 74f2521
Show file tree
Hide file tree
Showing 5 changed files with 218 additions and 60 deletions.
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,18 +250,19 @@ 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

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:

Expand All @@ -275,7 +276,7 @@ $effect(
throw new Error("test");
},
{
errorHandler: customErrorHandlerFn
onError: customErrorHandlerFn
}
);
```
Expand All @@ -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";
}
Expand All @@ -304,7 +311,7 @@ $computed(
throw new Error("test");
},
{
errorHandler: customErrorHandlerFn
onError: customErrorHandlerFn
}
);
```
Expand Down
58 changes: 37 additions & 21 deletions force-app/lwc/signals/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -225,6 +238,9 @@ function $signal(value, options) {
get value() {
return getter();
}
},
peek() {
return _storageOption.get();
}
};
delete returnValue.get;
Expand Down
42 changes: 39 additions & 3 deletions src/lwc/signals/__tests__/computed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});

Expand All @@ -109,7 +114,7 @@ describe("computed values", () => {
$computed(() => {
throw new Error("test");
}, {
errorHandler: customErrorHandlerFn
onError: customErrorHandlerFn
});

expect(customErrorHandlerFn).toHaveBeenCalled();
Expand All @@ -123,7 +128,7 @@ describe("computed values", () => {
const computed = $computed(() => {
throw new Error("test");
}, {
errorHandler: customErrorHandlerFn
onError: customErrorHandlerFn
});

expect(computed.value).toBe("fallback");
Expand All @@ -142,7 +147,7 @@ describe("computed values", () => {

return signal.value;
}, {
errorHandler: customErrorHandlerFn
onError: customErrorHandlerFn
});

expect(computed.value).toBe(0);
Expand All @@ -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);
});
});
73 changes: 72 additions & 1 deletion src/lwc/signals/__tests__/effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(() => {
Expand Down
Loading

0 comments on commit 74f2521

Please sign in to comment.