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