|
| 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