Skip to content

Commit c05e18d

Browse files
Standard Signals (#39)
1 parent 2f84e0a commit c05e18d

File tree

1 file changed

+171
-0
lines changed

1 file changed

+171
-0
lines changed

Diff for: rfcs/0005-standard-signals.md

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
---
2+
Status: Active
3+
Champions: "@justinfagnani"
4+
PR: https://github.com/lit/rfcs/pull/39
5+
---
6+
7+
# Standard Signals
8+
9+
A new labs package that integrates the new TC39 standard signals proposal with Lit.
10+
11+
## Objective
12+
13+
Signals are now [proposed to be standardized as part of JavaScript](https://github.com/proposal-signals/proposal-signals). This would solve an issue we have with previous signals integrations where we have to chose a signals library to integrate with. Standard signals gives us an _interoperable_ primitive for shared observable state.
14+
15+
The signals proposal includes a [polyfill](https://github.com/proposal-signals/proposal-signals/tree/main/packages/signal-polyfill) (actually a ponyfill) that seems fairly ready to use as a userland implementation. This means that we can implement provisional integration today.
16+
17+
Since this is a non-core packages that's versioned indepently from the core libraries, we can ship this integration early and manage breaking changes in the signals proposal with semver major releases.
18+
19+
### Goals
20+
- Enable Lit elements to use signals in their update lifecycle methods, and trigger updates when the signals change.
21+
- Work with standard signals produced by any wrapper library or framework (no proprietary extensions or hooks).
22+
- Allow fine-grained DOM updates from individual signals.
23+
- Integrate signal-driven updates with the ReactElement lifecycle.
24+
- Give real-world, production-tested, feedback to the signals champions.
25+
26+
## Motivation
27+
28+
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.
29+
30+
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.
31+
32+
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.
33+
34+
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.
35+
36+
Lit doesn't have a built-in or endorsed shared *observable* state system. Properties can be passed down a component tree, and the `@lit/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:
37+
38+
* State management libraries like Redux or MobX
39+
* Observables like RxJS
40+
* Building a custom system on the EventTarget API, or a custom callback.
41+
42+
Signals offer another option with a developer experience that is popular.
43+
44+
## Detailed Design
45+
46+
### User-facing APIs
47+
The signals package will offer three ways to use signals:
48+
- A class mixin that watches the entire update lifecycle and causes the whole element to re-render upon signal updates.
49+
- A directive that applies a single signal to a binding
50+
- A customized `html` template tag that automatically applies the directive to signal-values objects.
51+
52+
### Observing signal updates
53+
54+
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.
55+
56+
Unlike most userland libraries, the standard signals proposal does not include an `effect()` helper (the intention is to leave effects and scheduling up to "frameworks"). Instead we must use a lower-level [_Watcher_ to watch signals and schedule updates](https://github.com/proposal-signals/proposal-signals/tree/main/packages/signal-polyfill).
57+
58+
We can use a Watcher in two ways:
59+
1. To watch a computed signal that wraps the update lifecycle so that we're notified of updates to any signal read within the lifecycle and can trigger a new update.
60+
2. To watch individual signals bound directly to the DOM with a `watch()` directive to update just that binding.
61+
62+
### SignalWatcher Mixin
63+
64+
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.
65+
66+
We can do this with an override of `performUpdate()` that wraps ReactiveElement's implementation in a watched computed signal:
67+
68+
```ts
69+
abstract class SignalWatcher extends Base {
70+
// Watcher.watch() doesn't dedupe :(
71+
private __watching = false;
72+
private __watcher = new Signal.subtle.Watcher(() => {
73+
this.requestUpdate();
74+
});
75+
private __updateSignal = new Signal.Computed(() => {
76+
super.performUpdate();
77+
});
78+
79+
override performUpdate() {
80+
if (this.isUpdatePending === false) {
81+
return;
82+
}
83+
this.__updateSignal.get();
84+
}
85+
86+
override connectedCallback(): void {
87+
if (!this.__watching) {
88+
this.__watching = true;
89+
this.__watcher.watch(this.__updateSignal);
90+
}
91+
super.connectedCallback();
92+
}
93+
94+
override disconnectedCallback(): void {
95+
if (this.__watching) {
96+
this.__watching = false;
97+
this.__watcher.unwatch(this.__updateSignal);
98+
}
99+
super.disconnectedCallback();
100+
}
101+
}
102+
```
103+
104+
### watch() directive
105+
106+
The `watch()` async directive accepts a signal and renders its value _asynchronously_ to the containing binding. When the signal changes, the binding value is updated directly.
107+
108+
Usage:
109+
```ts
110+
html`<p>${watch(messageSignal)}</p>`
111+
```
112+
113+
We cannot write to the DOM synchronously like with `@lit-labs/preact-signals` because the standard signals proposal disallows reading signals from withing a watcher callback.
114+
115+
Instead the updates must be asynchronous, but they can still be fine-grained. In cases where we have multiple signals updates and/or a element update in a microtask we will synchronize fine-grained updates and the element's update lifecycle.
116+
117+
#### Static analysis of watch()
118+
119+
`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.
120+
121+
### Auto-watching template tag
122+
123+
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.
124+
125+
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 or multiple realms, the signals proposal doesn't include a `Signal.isSignal()` helper yet.
126+
127+
It may sometimes be neccessary to forward a signal object through a binding, rather than watching it. To support this we will add a special object to box signals to indicate that they should be passed directly to the part. A `signalRef()` function will box the signal at at the binding site. This would only work with property bindings.
128+
129+
## Implementation Considerations
130+
131+
### Implementation Plan
132+
133+
Implementation should be straight forward. We'll create a new `@lit-labs/signals` package with the three APIs proposed here. There is nothing needed in core to support this.
134+
135+
#### lit-analyzer
136+
137+
After the library is launched, we need to check that the `watch()` directive is analyzed correctly and if not, fix it.
138+
139+
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.
140+
141+
### Backward Compatibility
142+
143+
No backward compatibility concerns.
144+
145+
### Testing Plan
146+
147+
Unit tests are sufficient for client-side rendering. We should also include server-side tests with the SSR fixture utility.
148+
149+
### Performance and Code Size Impact
150+
151+
No impact on core library size or performance.
152+
153+
### Interoperability
154+
155+
This RFC greatly improves on interoperability compared to the `@lit-labs/preact-signals` package.
156+
157+
### Security Impact
158+
159+
None
160+
161+
### Documentation Plan
162+
163+
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.
164+
165+
## Downsides
166+
167+
None
168+
169+
## Alternatives
170+
171+
None

0 commit comments

Comments
 (0)