-
Notifications
You must be signed in to change notification settings - Fork 438
feat: signals implementation v1 #3963
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
Changes from 7 commits
4a0e5f7
f7f658d
0a08b45
66f885b
65fa1cb
8037a6c
7b3582f
92413fc
fcdae16
cf3bafd
b06528b
f315852
4ec0439
3c78f17
e3b6e82
2fa1693
1565a04
6fde22a
3c6aa00
1565db7
6360bb6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -11,6 +11,7 @@ import { | |||||
| valueMutated, | ||||||
| valueObserved, | ||||||
| } from '../libs/mutation-tracker'; | ||||||
| import { subscribeToSignal } from '../libs/signal-tracker'; | ||||||
| import { VM } from './vm'; | ||||||
|
|
||||||
| const DUMMY_REACTIVE_OBSERVER = { | ||||||
|
|
@@ -28,10 +29,29 @@ export function componentValueMutated(vm: VM, key: PropertyKey) { | |||||
| } | ||||||
| } | ||||||
|
|
||||||
| export function componentValueObserved(vm: VM, key: PropertyKey) { | ||||||
| export function componentValueObserved(vm: VM, key: PropertyKey, target: any = {}) { | ||||||
| const { component, tro } = vm; | ||||||
| // On the server side, we don't need mutation tracking. Skipping it improves performance. | ||||||
| if (process.env.IS_BROWSER) { | ||||||
| valueObserved(vm.component, key); | ||||||
| valueObserved(component, key); | ||||||
| } | ||||||
|
|
||||||
| // The portion of reactivity that's exposed to signals is to subscribe a callback to re-render the VM (templates). | ||||||
| // We check check the following to ensure re-render is subscribed at the correct time. | ||||||
| // 1. The template is currently being rendered (there is a template reactive observer) | ||||||
| // 2. There was a call to a getter to access the signal (happens during vnode generation) | ||||||
| if ( | ||||||
| lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS && | ||||||
| target && | ||||||
| typeof target === 'object' && | ||||||
jmsjtu marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||
| 'value' in target && | ||||||
| 'subscribe' in target && | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
No need since you're already checking it's a function below.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nolanlawson typescript is complaining when I do |
||||||
| typeof target.subscribe === 'function' && | ||||||
jmsjtu marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||
| // Only subscribe if a template is being rendered by the engine | ||||||
| tro.isObserving() | ||||||
| ) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These conditions look right to me for now. Sometime after this PR lands, we may want to replace the last three conditions with something like |
||||||
| // Subscribe the template reactive observer's notify method, which will mark the vm as dirty and schedule hydration. | ||||||
| subscribeToSignal(component, target, tro.notify.bind(tro)); | ||||||
|
||||||
| subscribeToSignal(component, target, tro.notify.bind(tro)); | |
| subscribeToSignal(component, target, FunctionBind.call(tro.notify, tro)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@nolanlawson it looks like we don't have a FunctionBind extracted in @lwc/shared.
I plan to open a separate PR to expose some of the common function methods and I'll update it here as part of the PR.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| /* | ||
| * Copyright (c) 2024, salesforce.com, inc. | ||
| * All rights reserved. | ||
| * SPDX-License-Identifier: MIT | ||
| * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT | ||
| */ | ||
| import { isFalse, isUndefined } from '@lwc/shared'; | ||
| import { Signal } from '@lwc/signals'; | ||
| import { logWarnOnce } from '../../shared/logger'; | ||
|
|
||
| /** | ||
| * This map keeps track of objects to signals. There is an assumption that the signal is strongly referenced | ||
| * on the object which allows the SignalTracker to be garbage collected along with the object. | ||
| */ | ||
| const TargetToSignalTrackerMap: WeakMap<Object, SignalTracker> = new WeakMap(); | ||
jmsjtu marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| function getSignalTracker(target: Object) { | ||
jmsjtu marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| let signalTracker = TargetToSignalTrackerMap.get(target); | ||
| if (isUndefined(signalTracker)) { | ||
| signalTracker = new SignalTracker(); | ||
| TargetToSignalTrackerMap.set(target, signalTracker); | ||
| } | ||
| return signalTracker; | ||
| } | ||
|
|
||
| export function subscribeToSignal( | ||
| target: Object, | ||
| signal: Signal<unknown>, | ||
| update: CallbackFunction | ||
| ) { | ||
| const signalTracker = getSignalTracker(target); | ||
| if (isFalse(signalTracker.seen(signal))) { | ||
| signalTracker.subscribeToSignal(signal, update); | ||
| } | ||
| } | ||
|
|
||
| export function unsubscribeFromSignals(target: Object) { | ||
| if (TargetToSignalTrackerMap.has(target)) { | ||
| const signalTracker = getSignalTracker(target); | ||
| signalTracker.unsubscribeFromSignals(); | ||
| signalTracker.reset(); | ||
| } | ||
| } | ||
|
|
||
| type CallbackFunction = () => void; | ||
|
|
||
| /** | ||
| * This class is used to keep track of the signals associated to a given object. | ||
| * It is used to prevent the LWC engine from subscribing duplicate callbacks multiple times | ||
| * to the same signal. Additionally, it keeps track of all signal unsubscribe callbacks, handles invoking | ||
| * them when necessary and discarding them. | ||
| */ | ||
| class SignalTracker { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I created this module to keep track of LWC component instances to signals bound the LWC (the signal is attached to the LWC class somewhere). The reason this is needed is because during the rendering process, if a signal is referenced more than once on a template its getter is called multiple times. Since we're hooking into the class' getter to determine whether a property access is a signal, there may be multiple attempts to subscribe the same callback to a single signal. I thought about reusing the The |
||
| private signalToUnsubscribeMap: Map<Signal<unknown>, CallbackFunction> = new Map(); | ||
|
|
||
| seen(signal: Signal<unknown>) { | ||
| return this.signalToUnsubscribeMap.has(signal); | ||
| } | ||
|
|
||
| subscribeToSignal(signal: Signal<unknown>, update: CallbackFunction) { | ||
| try { | ||
| const unsubscribe = signal.subscribe(update); | ||
| this.signalToUnsubscribeMap.set(signal, unsubscribe); | ||
| } catch (err) { | ||
| logWarnOnce( | ||
| `Attempted to subscribe to an object that has the shape of a signal but received the following error: ${err}` | ||
| ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should probably have a call to action here, i.e. tell the user what they need to do to resolve the error. AIUI, this will occur if e.g. a plain object has a shape like If that's the case, then I don't know if we even want to warn here.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're checking We definitely don't want errors in
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nolanlawson the signal shape should be I'll have to do some more research on how other frameworks handle this situation but I know most of them have their own signal implementations, so they may not run into this issue. I'll log the stack trace for now as @divmain suggested and look into how other frameworks handle this in more detail. |
||
| } | ||
| } | ||
|
|
||
| unsubscribeFromSignals() { | ||
| try { | ||
| this.signalToUnsubscribeMap.forEach((unsubscribe) => unsubscribe()); | ||
| } catch (err) { | ||
| logWarnOnce( | ||
| `Attempted to call a signal's unsubscribe callback but received the following error: ${err}` | ||
| ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one seems a bit more serious to me. In this case, we might have landed here because a signal exposed a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Elsewhere, we are checking that Same ask as earlier for preserving the error's stack.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nolanlawson In For v1 I'll log the stack trace as @divmain suggested but I left a todo (#3978) to revisit this in 252 once we figure out the use cases and know how we want to better handle the errors. |
||
| } | ||
| } | ||
|
|
||
| reset() { | ||
| this.signalToUnsubscribeMap.clear(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| # @lwc/signals | ||
|
|
||
| This is an experimental package containing the interface expected for signals. | ||
|
|
||
| A key point to note is that when a signal is both bound to an LWC class member variable and used on a template, | ||
| the LWC engine will attempt to subscribe a callback to rerender the template. | ||
|
Comment on lines
+3
to
+6
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wrote a quick
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As mentioned above, I think for now this should probably just be in
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nolanlawson let's chat offline about this. Having a separate package will help facilitate the related work that is coevolving. |
||
|
|
||
| ## Reactivity with Signals | ||
|
|
||
| A Signal is an object that holds a value and allows components to react to changes to that value. | ||
| It exposes a `.value` property for accessing the current value, and `.subscribe` methods for responding to changes. | ||
|
|
||
| ```js | ||
| import { signal } from 'some/signals'; | ||
|
|
||
| export default class ExampleComponent extends LightningElement { | ||
| count = signal(0); | ||
|
|
||
| increment() { | ||
| this.count.value++; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| In the template, we can bind directly to the `.value` property: | ||
|
|
||
| ```html | ||
| <template> | ||
| <button onclick="{increment}">Increment</button> | ||
| <p>{count.value}</p> | ||
| </template> | ||
| ``` | ||
|
|
||
| ## Supported APIs | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How quickly is the code in the README gonna get out of date? 😅
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This problem isn't unique to |
||
|
|
||
| This package supports the following APIs. | ||
|
|
||
| ### Signal | ||
|
|
||
| This is the shape of the signal that the LWC engine expects. | ||
|
|
||
| ```js | ||
| export type OnUpdate = () => void; | ||
| export type Unsubscribe = () => void; | ||
|
|
||
| export interface Signal<T> { | ||
| get value(): T; | ||
| subscribe(onUpdate: OnUpdate): Unsubscribe; | ||
| } | ||
| ``` | ||
|
|
||
| ### SignalBaseClass | ||
|
|
||
| A base class is provided as a starting point for implementation. | ||
|
|
||
| ```js | ||
| export abstract class SignalBaseClass<T> implements Signal<T> { | ||
| abstract get value(): T; | ||
|
|
||
| private subscribers: Set<OnUpdate> = new Set(); | ||
|
|
||
| subscribe(onUpdate: OnUpdate) { | ||
| this.subscribers.add(onUpdate); | ||
| return () => { | ||
| this.subscribers.delete(onUpdate); | ||
| }; | ||
| } | ||
|
|
||
| protected notify() { | ||
| for (const subscriber of this.subscribers) { | ||
| subscriber(); | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| /* | ||
| * Copyright (c) 2018, salesforce.com, inc. | ||
| * All rights reserved. | ||
| * SPDX-License-Identifier: MIT | ||
| * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT | ||
| */ | ||
| const BASE_CONFIG = require('../../../scripts/jest/base.config'); | ||
|
|
||
| module.exports = { | ||
| ...BASE_CONFIG, | ||
| displayName: 'lwc-signals', | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,44 @@ | ||||||||
| { | ||||||||
| "//": [ | ||||||||
| "THIS FILE IS AUTOGENERATED. If you modify it, it will be rewritten by check-and-rewrite-package-json.js", | ||||||||
| "You can safely modify dependencies, devDependencies, keywords, etc., but other props will be overwritten." | ||||||||
| ], | ||||||||
| "name": "@lwc/signals", | ||||||||
| "version": "6.0.0", | ||||||||
| "description": "Provides the protocol to to expose reactivity outside of the LWC framework", | ||||||||
| "keywords": [ | ||||||||
| "lwc" | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
🤷 |
||||||||
| ], | ||||||||
| "homepage": "https://lwc.dev", | ||||||||
| "repository": { | ||||||||
| "type": "git", | ||||||||
| "url": "https://github.com/salesforce/lwc.git", | ||||||||
| "directory": "packages/@lwc/signals" | ||||||||
| }, | ||||||||
| "bugs": { | ||||||||
| "url": "https://github.com/salesforce/lwc/issues" | ||||||||
| }, | ||||||||
| "license": "MIT", | ||||||||
| "publishConfig": { | ||||||||
| "access": "public" | ||||||||
| }, | ||||||||
| "main": "dist/index.cjs.js", | ||||||||
| "module": "dist/index.js", | ||||||||
| "types": "dist/index.d.ts", | ||||||||
| "files": [ | ||||||||
| "dist" | ||||||||
| ], | ||||||||
| "scripts": { | ||||||||
| "build": "rollup --config ../../../scripts/rollup/rollup.config.js", | ||||||||
| "dev": "rollup --config ../../../scripts/rollup/rollup.config.js --watch --no-watch.clearScreen" | ||||||||
| }, | ||||||||
| "nx": { | ||||||||
| "targets": { | ||||||||
| "build": { | ||||||||
| "outputs": [ | ||||||||
| "{projectRoot}/dist" | ||||||||
| ] | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be a dependency, or else it's gonna break things! (Alternatively, make the imports in
mutation-tracker/signal-trackerconditional.)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@wjhsf The way
devDependencieswork in this repo is that they get inlined into the builtdistfiles. So we use this as a kind of hack when we want certain built files not to contain anyrequires/imports.In this case, I think this is the right approach since historically
@lwc/engine-coredoesn't have any imports. The question then is why not just make@lwc/signalspart of@lwc/engine-coreitself.The answer to that, I think, is that
@lwc/signalskind of acts as a documentation hub and an ergonomic way to import a base class that consumers can extend. The value of this does seem debatable to me (we could just export the same thing from@lwc/engine-dom/lwc), and historically we've annoyed users with a proliferation of@lwc/*repos they've had to install, and plus this is an experimental feature, so I would lean towards just moving it back to@lwc/engine-corefor now.