Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal bind shorthand #37

Merged
merged 17 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
### 🚀 Release Notes

**Version**: 1.4.1
**Date**: 2025-01-22

### 🐞 Bug Fixes

- Build Last Change ([#40](https://github.com/cesarParra/lwc-signals/issues/40)) ([df8b1e8](https://github.com/cesarParra/lwc-signals/commit/df8b1e85f53f7e2510a6a5d96529b5a0eecf9f09))

### 🚀 Release Notes

**Version**: 1.4.0
**Date**: 2025-01-21

### ✨ Features

- circular dependecy management logic update ([128d330](https://github.com/cesarParra/lwc-signals/commit/128d33040e6c91191ecf9db117cc334b0a2a63ee))

### 🚀 Release Notes

**Version**: 1.3.0
**Date**: 2024-12-20

Expand Down
57 changes: 50 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ A simple yet powerful reactive state management solution for Lightning Web Compo
---

![GitHub Workflow Status](https://github.com/cesarParra/lwc-signals/actions/workflows/ci.yml/badge.svg)
![Version](https://img.shields.io/badge/version-1.3.0-blue)
![Version](https://img.shields.io/badge/version-1.4.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 implementation for Lightning Web Components.
Expand Down Expand Up @@ -117,7 +117,7 @@ clicked.
</template>
```

To update the counter, you can simply change the `counter.value` property directly.
To update the counter, you can change the `counter.value` property directly.

```javascript
// counter.js
Expand All @@ -137,9 +137,35 @@ export default class Counter extends LightningElement {

## Reacting to changes

### `$computed`
### Through `$bind`

You can use the `$computed` function to create a reactive value that depends on the signal.
You can use the `$bind` function to create a reactive value that depends on the signal.

```javascript
// display.js
import { LightningElement } from "lwc";
import { $bind } from "c/signals";
import { counter } from "c/counter-signals";

export default class Display extends LightningElement {
counterProp = $bind(this, "counterProp").to(counter);
}
```

Note that the first argument to the `$bind` function is the `this` context of the component, and the second argument
is the name of the property that will be created on the component as a string. Then you call the `.to` function with
the signal you want to bind to.

<p align="center">
<img src="./doc-assets/counter-example.gif" alt="Counter Example" />
</p>

### Through `$computed`

One downside of using `$bind` is that the second argument is a string, which can lead to typos and errors if the
property name is changed but the string is not updated.

So, alternatively, you can use the `$computed` function to create a reactive value that depends on the signal.

```javascript
// display.js
Expand All @@ -152,13 +178,30 @@ export default class Display extends LightningElement {
}
```

But notice that this syntax is a lot more verbose than using `$bind`.

> ❗ Note that in the callback function we **need** to reassign the value to `this.counter`
> to trigger the reactivity. This is because we need the value to be reassigned so that
> LWC reactive system can detect the change and update the UI.
<p align="center">
<img src="./doc-assets/counter-example.gif" alt="Counter Example" />
</p>
### Through `$effect`

Finally, you can use the `$effect` function to create a side effect that depends on the signal.

```javascript
// display.js
import { LightningElement } from "lwc";
import { $effect } from "c/signals";
import { counter } from "c/counter-signals";

export default class Display extends LightningElement {
counter = 0;

constructor() {
$effect(() => (this.counter = counter.value));
}
}
```

#### Stacking computed values

Expand Down
3 changes: 2 additions & 1 deletion examples/counter/lwc/countTracker/countTracker.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<template>
The current count is ($computed reactive property): {reactiveProperty} <br />
Binded: {bindTest} The current count is ($computed reactive property):
{reactiveProperty} <br />
The counter plus two value is (nested computed): {counterPlusTwo}
</template>
4 changes: 3 additions & 1 deletion examples/counter/lwc/countTracker/countTracker.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { LightningElement } from "lwc";
import { $computed } from "c/signals";
import { $binded, $computed } from "c/signals";
import { counter, counterPlusTwo } from "c/demoSignals";

export default class CountTracker extends LightningElement {
bindTest = $binded(this, "bindTest").to(counter);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand why you are doing this but I don't love it. Not sure what would be a better way to do the binding

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's why I'm keeping it as a proposal stage until I get some feedback, not sure if you saw the issue I put up for discussion: #38

I don't want to merge if its going to be detrimental compare to what exists today, but I haven't found a better way of doing it with how Javascript works today. The only way I can think this could be done is by using decorators, but JS doesn't support those yet.


reactiveProperty = $computed(() => (this.reactiveProperty = counter.value))
.value;

Expand Down
45 changes: 41 additions & 4 deletions force-app/lwc/signals/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const UNSET = Symbol("UNSET");
const COMPUTING = Symbol("COMPUTING");
const ERRORED = Symbol("ERRORED");
const READY = Symbol("READY");
// The maximum stack depth value is derived from Salesforce's maximum stack depth limit in triggers.
// This value is chosen to prevent infinite loops while still allowing for a reasonable level of recursion.
const MAX_STACK_DEPTH = 16;
const defaultEffectOptions = {
_fromComputed: false,
identifier: Symbol()
Expand Down Expand Up @@ -38,15 +41,22 @@ function $effect(fn, options) {
const _optionsWithDefaults = { ...defaultEffectOptions, ...options };
const effectNode = {
error: null,
state: UNSET
state: UNSET,
stackDepth: 0
};
const execute = () => {
if (effectNode.state === COMPUTING) {
throw new Error("Circular dependency detected");
if (
effectNode.state === COMPUTING &&
effectNode.stackDepth >= MAX_STACK_DEPTH
) {
throw new Error(
`Circular dependency detected. Maximum stack depth of ${MAX_STACK_DEPTH} exceeded.`
);
}
context.push(execute);
try {
effectNode.state = COMPUTING;
effectNode.stackDepth++;
fn();
effectNode.error = null;
effectNode.state = READY;
Expand All @@ -58,6 +68,9 @@ function $effect(fn, options) {
: handleEffectError(error, _optionsWithDefaults);
} finally {
context.pop();
if (effectNode.stackDepth > 0) {
effectNode.stackDepth--;
}
}
};
execute();
Expand Down Expand Up @@ -348,4 +361,28 @@ function $resource(fn, source, options) {
function isSignal(anything) {
return !!anything && anything.brand === SIGNAL_OBJECT_BRAND;
}
export { $signal, $effect, $computed, $resource, isSignal };
class Binder {
constructor(component, propertyName) {
this.component = component;
this.propertyName = propertyName;
}
to(signal) {
$effect(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this ok performance wise?

// @ts-expect-error The property name will be found
this.component[this.propertyName] = signal.value;
});
return signal.value;
}
}
function bind(component, propertyName) {
return new Binder(component, propertyName);
}
export {
$signal,
$effect,
$computed,
$resource,
bind,
bind as $bind,
isSignal
};
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "lwc-signals",
"private": true,
"version": "1.3.0",
"version": "1.4.1",
"scripts": {
"test": "jest",
"build": "tsc",
Expand Down
10 changes: 10 additions & 0 deletions src/lwc/signals/__tests__/effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,14 @@ describe("effects", () => {

expect(counter.value).toBe(1);
});

test("throws error when circular dependency exceeds depth limit", () => {
const signal = $signal(0);

expect(() => {
$effect(() => {
signal.value = signal.value + 1;
});
}).toThrow(/Circular dependency detected. Maximum stack depth of \d+ exceeded./);
});
});
51 changes: 47 additions & 4 deletions src/lwc/signals/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,14 @@ const COMPUTING = Symbol("COMPUTING");
const ERRORED = Symbol("ERRORED");
const READY = Symbol("READY");

// The maximum stack depth value is derived from Salesforce's maximum stack depth limit in triggers.
// This value is chosen to prevent infinite loops while still allowing for a reasonable level of recursion.
const MAX_STACK_DEPTH = 16;

interface EffectNode {
error: unknown;
state: symbol;
stackDepth: number;
}

type EffectOptions = {
Expand Down Expand Up @@ -70,17 +75,24 @@ function $effect(fn: VoidFunction, options?: Partial<EffectOptions>): Effect {
const _optionsWithDefaults = { ...defaultEffectOptions, ...options };
const effectNode: EffectNode = {
error: null,
state: UNSET
state: UNSET,
stackDepth: 0
};

const execute = () => {
if (effectNode.state === COMPUTING) {
throw new Error("Circular dependency detected");
if (
effectNode.state === COMPUTING &&
effectNode.stackDepth >= MAX_STACK_DEPTH
) {
throw new Error(
`Circular dependency detected. Maximum stack depth of ${MAX_STACK_DEPTH} exceeded.`
);
}

context.push(execute);
try {
effectNode.state = COMPUTING;
effectNode.stackDepth++;
fn();
effectNode.error = null;
effectNode.state = READY;
Expand All @@ -92,6 +104,9 @@ function $effect(fn: VoidFunction, options?: Partial<EffectOptions>): Effect {
: handleEffectError(error, _optionsWithDefaults);
} finally {
context.pop();
if (effectNode.stackDepth > 0) {
effectNode.stackDepth--;
}
}
};

Expand Down Expand Up @@ -579,4 +594,32 @@ function isSignal(anything: unknown): anything is Signal<unknown> {
);
}

export { $signal, $effect, $computed, $resource, isSignal };
class Binder {
constructor(
private component: Record<string, object>,
private propertyName: string
) {}

to(signal: Signal<unknown>) {
$effect(() => {
// @ts-expect-error The property name will be found
this.component[this.propertyName] = signal.value;
});

return signal.value;
}
}

function bind(component: Record<string, object>, propertyName: string) {
return new Binder(component, propertyName);
}

export {
$signal,
$effect,
$computed,
$resource,
bind,
bind as $bind,
isSignal
};