Skip to content

Commit f46f942

Browse files
Preact Signals (#19)
1 parent e2ffa60 commit f46f942

File tree

2 files changed

+197
-1
lines changed

2 files changed

+197
-1
lines changed

rfcs/0000-rfc-template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ What is the RFC trying to accomplish?
2020

2121
## Motivation
2222

23-
Why is the RFC neccessary? What background information is needed to understand why?
23+
Why is the RFC necessary? What background information is needed to understand why?
2424

2525
## Detailed Design
2626

rfcs/0003-preact-signals.md

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
---
2+
Status: Active
3+
Champions: "@justinfagnani @rictic"
4+
PR: https://github.com/lit/rfcs/pull/19
5+
---
6+
7+
# Preact Signals
8+
9+
A new labs package with Preact signals integration for Lit.
10+
11+
## Objective
12+
13+
We want to enable use of signal libraries with Lit, as an option for observable shared state. There are many signals libraries out there, and they are not generically compatible, so for now Lit would need integration with each library developers want to use. This RFC proposes [Preact Signals](https://preactjs.com/guide/v10/signals/) as one such library that we should provide integration for.
14+
15+
### Goals
16+
- Enable Lit elements to use signals in their update lifecycle methods, and trigger updates when the signals change.
17+
18+
### Non-Goals
19+
- Build a Lit-specific signal library
20+
21+
## Motivation
22+
23+
Signals are taking the web frontend world by storm. While they are but one approach to share observable state, they are an increasingly popular one right now.
24+
25+
Part of the excitement around signals is their ability to improve performance in frameworks that otherwise can have some poor update performance due to expensive VDOM diffs. Signals circumvent this issue by letting data updates skip the VDOM diff and directly induce an update on the DOM.
26+
27+
Lit doesn't have this problem. Instead of a VDOM diff against the whole DOM tree, Lit does inexpensive strict equality checks against previous binding values at dynamic binding sites only, and then only updates the DOM controlled by those bindings. This generally makes re-render performance be fast enough that signals aren't necessary for performance. In fact, one way to look at a Lit template is as a computed signal that depends on the host's reactive properties, and a Lit component as a signal that depends on the properties provided by it's parent.
28+
29+
Where signals can possibly be a major improvement for component authoring is as a shared observable state primitive. This may also have some performance benefits by allowing state updates via signals to bypass the top-down rendering of the component tree.
30+
31+
Lit doesn't have a built-in or endorsed shared *observable* state system. Properties can be passed down a component tree, and the `@lit-labs/context` package allows sharing of values across a tree, but to observe changes to the individual data objects themselves, developers have to choose from a number of possible solutions, such as:
32+
33+
* State management libraries like Redux or MobX
34+
* Observables like RxJS
35+
* Building a custom system on the EventTarget API, or a custom callback.
36+
37+
Signals offer another option with a developer experience that is popular.
38+
39+
## Detailed Design
40+
41+
To enable observing signal changes, we need to run access to a signal in an _effect_ - a closure that contains the signal access and will be called again when the accessed signals change.
42+
43+
Preact signals exports the `effect()` method for this:
44+
45+
```ts
46+
import {effect} from '@preact/signals-core';
47+
48+
export const logSignal = (s: Signal<unknown>) =>
49+
effect(() => {
50+
// Run when someSignal changes
51+
console.log(s.value);
52+
});
53+
```
54+
55+
We intend to offer three ways to automatically run signal access inside an effect:
56+
- A class mixin that runs the entire update lifecycle in an effect and causes the whole element to re-render upon changes.
57+
- A directive that applies a single signal to a binding
58+
- A customized `html` template tag that automatically applies the directive to signal-values objects.
59+
60+
### SignalWatcher Mixin
61+
62+
Conceptually, we want to run the reactive update lifecycle in an effect so that the signal library observes access to signals and trigger a new update.
63+
64+
We can do this with an override of `performUpdate()` that wraps ReactiveElement's implementation in an effect:
65+
66+
```ts
67+
private _disposeEffect?: () => void;
68+
69+
override performUpdate() {
70+
// ReactiveElement.performUpdate() also does this check, so we want to
71+
// also bail early so we don't erroneously appear to not depend on any
72+
// signals.
73+
if (!this.isUpdatePending) {
74+
return;
75+
}
76+
// If we have a previous effect, dispose it
77+
this._disposeEffect?.();
78+
79+
// We create a new effect to capture all signal access within the
80+
// performUpdate phase (update, render, updated, etc) of the element.
81+
this._disposeEffect = effect(() => {
82+
// When Signals change we need to re-render, but we need to get past
83+
// the isUpdatePending in performUpdate(), so we set it to true.
84+
this.isUpdatePending = true;
85+
// We call super.performUpdate() so that we don't create a new effect
86+
// only as the result of the effect running.
87+
super.performUpdate();
88+
});
89+
}
90+
```
91+
92+
An issue with this approach is that we bypass the reactive update lifecycle when signals change. We would like to integrate updates from signals and reactive properties into one batch. To do this we need separate watch and update code paths. Preact Signals don't have such an API, but we should be able to simulate it:
93+
94+
```ts
95+
override performUpdate() {
96+
if (!this.isUpdatePending) {
97+
return;
98+
}
99+
this._disposeEffect?.();
100+
let updateFromLit = true;
101+
this._disposeEffect = effect(() => {
102+
if (updateFromLit) {
103+
super.performUpdate();
104+
} else {
105+
// This branch is an effect run from Preact signals.
106+
// This will cause another call into performUpdate, which will
107+
// then create a new effect watching that update pass.
108+
this.requestUpdate();
109+
}
110+
updateFromLit = false;
111+
});
112+
}
113+
```
114+
115+
### watch() directive
116+
117+
The `watch()` async directive accepts a signal and renders its value synchronously to the containing binding. When the signal changes, the binding value is updated directly.
118+
119+
Usage:
120+
```ts
121+
html`<p>${watch(messageSignal)}</p>`
122+
```
123+
124+
```ts
125+
class WatchSignal extends AsyncDirective {
126+
render<T>(signal: Signal<T>): T {
127+
let updateFromLit = true;
128+
effect(() => {
129+
// Access the signal unconditionally to watch it
130+
const value = signal.value;
131+
if (!updateFromLit) {
132+
this.setValue(value);
133+
}
134+
updateFromLit = false;
135+
});
136+
return signal.peek();
137+
}
138+
139+
// disconnected / reconnected too
140+
}
141+
export const watch = directive(WatchSignal);
142+
```
143+
144+
#### Static analysis of watch()
145+
146+
`watch()` essentially unwraps a `Signal<T>`. This should be analyzable by template analyzers like lit-analyzer, but we need to check and ensure this is the case.
147+
148+
### Auto-watching template tag
149+
150+
Auto-watching versions of `html` and `svg` template tags will scan a template result's values and automatically wrap them in a `watch()` directive if they are signals.
151+
152+
We should be able to detect signals with `value instanceof Signal`. While the `instanceof` operator is fragile, especially in the presence of multiple copies of a module, it appears that the Preact Signals modules are _already_ fragile in this respect due to module-scoped state: all signals, computed signals, and effects, must use the same module instance to work. We have filed an [issue for a more robust check](https://github.com/preactjs/signals/issues/402) and will revist this topic when that issue is addressed.
153+
154+
## Implementation Considerations
155+
156+
### Implementation Plan
157+
158+
Implementation should be straight forward. We'll create a new `@lit/labs/preact-signals` package with the three APIs proposed here. There is nothing needed in core to support this.
159+
160+
#### lit-analyzer
161+
162+
After the library is launched, we need to check that the `watch()` directive is analyzed correctly and if not, fix it.
163+
164+
Currently, the auto-watching `html` tag will not be analyzed correctly. We should investigate if we could annotate a tag such that the analyzer knows that expressions may be wrapped, so that we don't have to hard-code support for this package.
165+
166+
### Backward Compatibility
167+
168+
No backward compatibility concerns.
169+
170+
### Testing Plan
171+
172+
Unit tests are sufficient for client-side rendering. We should also include server-side tests with the SSR fixture utility.
173+
174+
### Performance and Code Size Impact
175+
176+
No impact on core library size or performance.
177+
178+
### Interoperability
179+
180+
Signals implementations are unfortunately not interoperable with each other. Signals are most appropriate for the internal state of a component, or share state amongst already tightly-coupled components, as in an app.
181+
182+
### Security Impact
183+
184+
None
185+
186+
### Documentation Plan
187+
188+
This package will initially be documented in its own README. If it stays on track to graduation, we should document this package under the *Managing Data* section on lit.dev. We may end up with multiple signals packages, and either none will graduate, one will, or we'll have to document multiple packages.
189+
190+
## Downsides
191+
192+
Implementing this RFC may appear as endorsing Preact Signals above other signals packages. This will be true to the extent that we will have built and tested the integration ourselves, so know it works. The downside is that other signals implementations may be as good or better choices, and our intention of Preact Signals integration only being the first integration could be overlooked causing people to not use the other packages. Something has to go first however.
193+
194+
## Alternatives
195+
196+
We could leave it to the community to build an integration. The Lit team is most familiar with the nuances of ReactiveElement update lifecycle, so we can build this quickly.

0 commit comments

Comments
 (0)