diff --git a/packages/@lwc/engine-core/package.json b/packages/@lwc/engine-core/package.json index 51a53cfd96..72b12ca945 100644 --- a/packages/@lwc/engine-core/package.json +++ b/packages/@lwc/engine-core/package.json @@ -46,6 +46,7 @@ "@lwc/shared": "6.1.1" }, "devDependencies": { - "observable-membrane": "2.0.0" + "observable-membrane": "2.0.0", + "@lwc/signals": "6.0.0" } } diff --git a/packages/@lwc/engine-core/src/framework/component.ts b/packages/@lwc/engine-core/src/framework/component.ts index 297333911c..8b4195947c 100644 --- a/packages/@lwc/engine-core/src/framework/component.ts +++ b/packages/@lwc/engine-core/src/framework/component.ts @@ -13,7 +13,11 @@ import { LOWEST_API_VERSION, } from '@lwc/shared'; -import { createReactiveObserver, ReactiveObserver } from './mutation-tracker'; +import { + createReactiveObserver, + ReactiveObserver, + unsubscribeFromSignals, +} from './mutation-tracker'; import { invokeComponentRenderMethod, isInvokingRender, invokeEventListener } from './invoker'; import { VM, scheduleRehydration } from './vm'; @@ -86,12 +90,28 @@ export function getTemplateReactiveObserver(vm: VM): ReactiveObserver { }); } +export function resetTemplateObserverAndUnsubscribe(vm: VM) { + const { tro, component } = vm; + tro.reset(); + // Unsubscribe every time the template reactive observer is reset. + if (lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS) { + unsubscribeFromSignals(component); + } +} + export function renderComponent(vm: VM): VNodes { if (process.env.NODE_ENV !== 'production') { assert.invariant(vm.isDirty, `${vm} is not dirty.`); } - - vm.tro.reset(); + // The engine should only hold a subscription to a signal if it is rendered in the template. + // Because of the potential presence of conditional rendering logic, we unsubscribe on each render + // in the scenario where it is present in one condition but not the other. + // For example: + // 1. There is an lwc:if=true conditional where the signal is present on the template. + // 2. The lwc:if changes to false and the signal is no longer present on the template. + // If the signal is still subscribed to, the template will re-render when it receives a notification + // from the signal, even though we won't be using the new value. + resetTemplateObserverAndUnsubscribe(vm); const vnodes = invokeComponentRenderMethod(vm); vm.isDirty = false; vm.isScheduled = false; diff --git a/packages/@lwc/engine-core/src/framework/decorators/api.ts b/packages/@lwc/engine-core/src/framework/decorators/api.ts index 6a652eb0a0..41a8c9c8b4 100644 --- a/packages/@lwc/engine-core/src/framework/decorators/api.ts +++ b/packages/@lwc/engine-core/src/framework/decorators/api.ts @@ -40,8 +40,9 @@ export function createPublicPropertyDescriptor(key: string): PropertyDescriptor } return; } - componentValueObserved(vm, key); - return vm.cmpProps[key]; + const val = vm.cmpProps[key]; + componentValueObserved(vm, key, val); + return val; }, set(this: LightningElement, newValue: any) { const vm = getAssociatedVM(this); diff --git a/packages/@lwc/engine-core/src/framework/decorators/track.ts b/packages/@lwc/engine-core/src/framework/decorators/track.ts index 66a1183ae6..fa2d34ad19 100644 --- a/packages/@lwc/engine-core/src/framework/decorators/track.ts +++ b/packages/@lwc/engine-core/src/framework/decorators/track.ts @@ -40,8 +40,9 @@ export function internalTrackDecorator(key: string): PropertyDescriptor { return { get(this: LightningElement): any { const vm = getAssociatedVM(this); - componentValueObserved(vm, key); - return vm.cmpFields[key]; + const val = vm.cmpFields[key]; + componentValueObserved(vm, key, val); + return val; }, set(this: LightningElement, newValue: any) { const vm = getAssociatedVM(this); diff --git a/packages/@lwc/engine-core/src/framework/mutation-tracker.ts b/packages/@lwc/engine-core/src/framework/mutation-tracker.ts index 1eb17544ac..2418682e3c 100644 --- a/packages/@lwc/engine-core/src/framework/mutation-tracker.ts +++ b/packages/@lwc/engine-core/src/framework/mutation-tracker.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ +import { isFunction, isNull, isObject } from '@lwc/shared'; +import { Signal } from '@lwc/signals'; import { JobFunction, CallbackFunction, @@ -11,6 +13,7 @@ import { valueMutated, valueObserved, } from '../libs/mutation-tracker'; +import { subscribeToSignal } from '../libs/signal-tracker'; import { VM } from './vm'; const DUMMY_REACTIVE_OBSERVER = { @@ -28,10 +31,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 && + isObject(target) && + !isNull(target) && + 'value' in target && + 'subscribe' in target && + isFunction(target.subscribe) && + // Only subscribe if a template is being rendered by the engine + tro.isObserving() + ) { + // Subscribe the template reactive observer's notify method, which will mark the vm as dirty and schedule hydration. + subscribeToSignal(component, target as Signal, tro.notify.bind(tro)); } } @@ -41,3 +63,4 @@ export function createReactiveObserver(callback: CallbackFunction): ReactiveObse } export * from '../libs/mutation-tracker'; +export * from '../libs/signal-tracker'; diff --git a/packages/@lwc/engine-core/src/framework/observed-fields.ts b/packages/@lwc/engine-core/src/framework/observed-fields.ts index 6321ab456a..29b3086f5d 100644 --- a/packages/@lwc/engine-core/src/framework/observed-fields.ts +++ b/packages/@lwc/engine-core/src/framework/observed-fields.ts @@ -13,8 +13,9 @@ export function createObservedFieldPropertyDescriptor(key: string): PropertyDesc return { get(this: LightningElement): any { const vm = getAssociatedVM(this); - componentValueObserved(vm, key); - return vm.cmpFields[key]; + const val = vm.cmpFields[key]; + componentValueObserved(vm, key, val); + return val; }, set(this: LightningElement, newValue: any) { const vm = getAssociatedVM(this); diff --git a/packages/@lwc/engine-core/src/framework/vm.ts b/packages/@lwc/engine-core/src/framework/vm.ts index d930222153..e4dc2e2b55 100644 --- a/packages/@lwc/engine-core/src/framework/vm.ts +++ b/packages/@lwc/engine-core/src/framework/vm.ts @@ -31,6 +31,7 @@ import { markComponentAsDirty, getTemplateReactiveObserver, getComponentAPIVersion, + resetTemplateObserverAndUnsubscribe, } from './component'; import { addCallbackToNextTick, @@ -272,9 +273,8 @@ function resetComponentStateWhenRemoved(vm: VM) { const { state } = vm; if (state !== VMState.disconnected) { - const { tro } = vm; // Making sure that any observing record will not trigger the rehydrated on this vm - tro.reset(); + resetTemplateObserverAndUnsubscribe(vm); runDisconnectedCallback(vm); // Spec: https://dom.spec.whatwg.org/#concept-node-remove (step 14-15) runChildNodesDisconnectedCallback(vm); diff --git a/packages/@lwc/engine-core/src/libs/mutation-tracker/index.ts b/packages/@lwc/engine-core/src/libs/mutation-tracker/index.ts index d68c4a99d6..93137c4fd9 100644 --- a/packages/@lwc/engine-core/src/libs/mutation-tracker/index.ts +++ b/packages/@lwc/engine-core/src/libs/mutation-tracker/index.ts @@ -125,4 +125,8 @@ export class ReactiveObserver { // we keep track of observing records where the observing record was added to so we can do some clean up later on ArrayPush.call(this.listeners, reactiveObservers); } + + isObserving() { + return currentReactiveObserver === this; + } } diff --git a/packages/@lwc/engine-core/src/libs/signal-tracker/index.ts b/packages/@lwc/engine-core/src/libs/signal-tracker/index.ts new file mode 100644 index 0000000000..7d16bf25a4 --- /dev/null +++ b/packages/@lwc/engine-core/src/libs/signal-tracker/index.ts @@ -0,0 +1,92 @@ +/* + * 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, isFunction, 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 = new WeakMap(); + +function getSignalTracker(target: object) { + let signalTracker = TargetToSignalTrackerMap.get(target); + if (isUndefined(signalTracker)) { + signalTracker = new SignalTracker(); + TargetToSignalTrackerMap.set(target, signalTracker); + } + return signalTracker; +} + +export function subscribeToSignal( + target: Object, + signal: Signal, + 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 { + private signalToUnsubscribeMap: Map, CallbackFunction> = new Map(); + + seen(signal: Signal) { + return this.signalToUnsubscribeMap.has(signal); + } + + subscribeToSignal(signal: Signal, update: CallbackFunction) { + try { + const unsubscribe = signal.subscribe(update); + if (isFunction(unsubscribe)) { + // TODO [#3978]: Evaluate how we should handle the case when unsubscribe is not a function. + // Long term we should throw an error or log a warning. + this.signalToUnsubscribeMap.set(signal, unsubscribe); + } + } catch (err: any) { + logWarnOnce( + `Attempted to subscribe to an object that has the shape of a signal but received the following error: ${ + err?.stack ?? err + }` + ); + } + } + + unsubscribeFromSignals() { + try { + this.signalToUnsubscribeMap.forEach((unsubscribe) => unsubscribe()); + } catch (err: any) { + logWarnOnce( + `Attempted to call a signal's unsubscribe callback but received the following error: ${ + err?.stack ?? err + }` + ); + } + } + + reset() { + this.signalToUnsubscribeMap.clear(); + } +} diff --git a/packages/@lwc/features/src/index.ts b/packages/@lwc/features/src/index.ts index 2d68e15d9a..277b8d0b60 100644 --- a/packages/@lwc/features/src/index.ts +++ b/packages/@lwc/features/src/index.ts @@ -18,6 +18,7 @@ const features: FeatureFlagMap = { ENABLE_FROZEN_TEMPLATE: null, ENABLE_LEGACY_SCOPE_TOKENS: null, ENABLE_FORCE_SHADOW_MIGRATE_MODE: null, + ENABLE_EXPERIMENTAL_SIGNALS: null, DISABLE_TEMPORARY_V5_COMPILER_SUPPORT: null, }; diff --git a/packages/@lwc/features/src/types.ts b/packages/@lwc/features/src/types.ts index 7d0cecc9bc..88e3a24f02 100644 --- a/packages/@lwc/features/src/types.ts +++ b/packages/@lwc/features/src/types.ts @@ -64,6 +64,12 @@ export interface FeatureFlagMap { */ ENABLE_FORCE_SHADOW_MIGRATE_MODE: FeatureFlagValue; + /** + * EXPERIMENTAL FEATURE, DO NOT USE IN PRODUCTION + * If true, allows the engine to expose reactivity to signals as describe in @lwc/signals. + */ + ENABLE_EXPERIMENTAL_SIGNALS: FeatureFlagValue; + /** * If true, disable temporary support for the LWC v5 compiler format. */ diff --git a/packages/@lwc/integration-karma/test/signal/protocol/index.spec.js b/packages/@lwc/integration-karma/test/signal/protocol/index.spec.js new file mode 100644 index 0000000000..22e7483848 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/index.spec.js @@ -0,0 +1,220 @@ +import { createElement, setFeatureFlagForTest } from 'lwc'; +import Reactive from 'x/reactive'; +import NonReactive from 'x/nonReactive'; +import Container from 'x/container'; +import Parent from 'x/parent'; +import Child from 'x/child'; +import DuplicateSignalOnTemplate from 'x/duplicateSignalOnTemplate'; +import List from 'x/list'; + +// Note for testing purposes the signal implementation uses LWC module resolution to simplify things. +// In production the signal will come from a 3rd party library. +import { Signal } from 'x/signal'; + +describe('signal protocol', () => { + beforeAll(() => { + setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', true); + }); + + afterAll(() => { + setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', false); + }); + + describe('lwc engine subscribes template re-render callback when signal is bound to an LWC and used on a template', () => { + [ + { + testName: 'contains a getter that references a bound signal (.value on template)', + flag: 'showGetterSignal', + }, + { + testName: 'contains a getter that references a bound signal value', + flag: 'showOnlyUsingSignalNotValue', + }, + { + testName: 'contains a signal with @api annotation (.value on template)', + flag: 'showApiSignal', + }, + { + testName: 'contains a signal with @track annotation (.value on template)', + flag: 'showTrackedSignal', + }, + { + testName: 'contains an observed field referencing a signal (.value on template)', + flag: 'showObservedFieldSignal', + }, + { + testName: 'contains a direct reference to a signal (not .value) in the template', + flag: 'showOnlyUsingSignalNotValue', + }, + ].forEach(({ testName, flag }) => { + // Test all ways of binding signal to an LWC + template that cause re-rendering + it(testName, async () => { + const elm = createElement('x-reactive', { is: Reactive }); + document.body.appendChild(elm); + await Promise.resolve(); + + expect(elm.getSignalSubscriberCount()).toBe(0); + elm[flag] = true; + await Promise.resolve(); + + // the engine will automatically subscribe the re-render callback + expect(elm.getSignalSubscriberCount()).toBe(1); + }); + }); + }); + + it('lwc engine should automatically unsubscribe the re-render callback if signal is not used on a template', async () => { + const elm = createElement('x-reactive', { is: Reactive }); + elm.showObservedFieldSignal = true; + document.body.appendChild(elm); + await Promise.resolve(); + + expect(elm.getSignalSubscriberCount()).toBe(1); + elm.showObservedFieldSignal = false; + await Promise.resolve(); + + expect(elm.getSignalSubscriberCount()).toBe(0); + document.body.removeChild(elm); + }); + + it('lwc engine does not subscribe re-render callback if signal is not used on a template', async () => { + const elm = createElement('x-non-reactive', { is: NonReactive }); + document.body.appendChild(elm); + await Promise.resolve(); + + expect(elm.getSignalSubscriberCount()).toBe(0); + }); + + it('only the components referencing a signal should re-render', async () => { + const container = createElement('x-container', { is: Container }); + // append the container first to avoid error message with native lifecycle + document.body.appendChild(container); + await Promise.resolve(); + + const signalElm = createElement('x-signal-elm', { is: Child }); + const signal = new Signal('initial value'); + signalElm.signal = signal; + container.appendChild(signalElm); + await Promise.resolve(); + + expect(container.renderCount).toBe(1); + expect(signalElm.renderCount).toBe(1); + expect(signal.getSubscriberCount()).toBe(1); + + signal.value = 'updated value'; + await Promise.resolve(); + + expect(container.renderCount).toBe(1); + expect(signalElm.renderCount).toBe(2); + expect(signal.getSubscriberCount()).toBe(1); + }); + + it('only subscribes the re-render callback a single time when signal is referenced multiple times on a template', async () => { + const elm = createElement('x-duplicate-signals-on-template', { + is: DuplicateSignalOnTemplate, + }); + document.body.appendChild(elm); + await Promise.resolve(); + + expect(elm.renderCount).toBe(1); + expect(elm.getSignalSubscriberCount()).toBe(1); + expect(elm.getSignalRemovedSubscriberCount()).toBe(0); + + elm.updateSignalValue(); + await Promise.resolve(); + + expect(elm.renderCount).toBe(2); + expect(elm.getSignalSubscriberCount()).toBe(1); + expect(elm.getSignalRemovedSubscriberCount()).toBe(1); + }); + + it('only subscribes re-render callback a single time when signal is referenced multiple times in a list', async () => { + const elm = createElement('x-list', { is: List }); + const signal = new Signal('initial value'); + elm.signal = signal; + document.body.appendChild(elm); + await Promise.resolve(); + + expect(signal.getSubscriberCount()).toBe(1); + expect(signal.getRemovedSubscriberCount()).toBe(0); + + document.body.removeChild(elm); + await Promise.resolve(); + + expect(signal.getSubscriberCount()).toBe(0); + expect(signal.getRemovedSubscriberCount()).toBe(1); + }); + + it('unsubscribes when element is removed from the dom', async () => { + const elm = createElement('x-child', { is: Child }); + const signal = new Signal('initial value'); + elm.signal = signal; + document.body.appendChild(elm); + await Promise.resolve(); + + expect(signal.getSubscriberCount()).toBe(1); + expect(signal.getRemovedSubscriberCount()).toBe(0); + + document.body.removeChild(elm); + await Promise.resolve(); + + expect(signal.getSubscriberCount()).toBe(0); + expect(signal.getRemovedSubscriberCount()).toBe(1); + }); + + it('on template re-render unsubscribes all components where signal is not present on the template', async () => { + const elm = createElement('x-parent', { is: Parent }); + elm.showChild = true; + + document.body.appendChild(elm); + await Promise.resolve(); + + // subscribed both parent and child + // as long as parent contains reference to the signal, even if it's just to pass it to a child + // it will be subscribed. + expect(elm.getSignalSubscriberCount()).toBe(2); + expect(elm.getSignalRemovedSubscriberCount()).toBe(0); + + elm.showChild = false; + await Promise.resolve(); + + // The signal is not being used on the parent template anymore so it will be removed + expect(elm.getSignalSubscriberCount()).toBe(0); + expect(elm.getSignalRemovedSubscriberCount()).toBe(2); + }); + + it('does not subscribe if the signal shape is incorrect', async () => { + const elm = createElement('x-child', { is: Child }); + const subscribe = jasmine.createSpy(); + // Note the signals property is value's' and not value + const signal = { values: 'initial value', subscribe }; + elm.signal = signal; + document.body.appendChild(elm); + await Promise.resolve(); + + expect(subscribe).not.toHaveBeenCalled(); + }); +}); + +describe('ENABLE_EXPERIMENTAL_SIGNALS not set', () => { + beforeAll(() => { + setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', false); + }); + + it('does not subscribe or unsubscribe if feature flag is disabled', async () => { + const elm = createElement('x-child', { is: Child }); + const signal = new Signal('initial value'); + elm.signal = signal; + document.body.appendChild(elm); + await Promise.resolve(); + + expect(signal.getSubscriberCount()).toBe(0); + expect(signal.getRemovedSubscriberCount()).toBe(0); + + document.body.removeChild(elm); + await Promise.resolve(); + + expect(signal.getSubscriberCount()).toBe(0); + expect(signal.getRemovedSubscriberCount()).toBe(0); + }); +}); diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/child/child.html b/packages/@lwc/integration-karma/test/signal/protocol/x/child/child.html new file mode 100644 index 0000000000..2428dd9e7c --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/child/child.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/child/child.js b/packages/@lwc/integration-karma/test/signal/protocol/x/child/child.js new file mode 100644 index 0000000000..76a657a00f --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/child/child.js @@ -0,0 +1,10 @@ +import { LightningElement, api } from 'lwc'; + +export default class extends LightningElement { + @api renderCount = 0; + @api signal; + + renderedCallback() { + this.renderCount++; + } +} diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/container/container.html b/packages/@lwc/integration-karma/test/signal/protocol/x/container/container.html new file mode 100644 index 0000000000..fba6288c0b --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/container/container.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/container/container.js b/packages/@lwc/integration-karma/test/signal/protocol/x/container/container.js new file mode 100644 index 0000000000..dbb973cc07 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/container/container.js @@ -0,0 +1,9 @@ +import { LightningElement, api } from 'lwc'; + +export default class extends LightningElement { + @api renderCount = 0; + + renderedCallback() { + this.renderCount++; + } +} diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.html b/packages/@lwc/integration-karma/test/signal/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.html new file mode 100644 index 0000000000..9e7f95c88d --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.js b/packages/@lwc/integration-karma/test/signal/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.js new file mode 100644 index 0000000000..67f2d836e1 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.js @@ -0,0 +1,26 @@ +import { LightningElement, api } from 'lwc'; +import { Signal } from 'x/signal'; + +export default class extends LightningElement { + signal = new Signal('initial value'); + @api renderCount = 0; + + renderedCallback() { + this.renderCount++; + } + + @api + getSignalSubscriberCount() { + return this.signal.getSubscriberCount(); + } + + @api + getSignalRemovedSubscriberCount() { + return this.signal.getRemovedSubscriberCount(); + } + + @api + updateSignalValue() { + this.signal.value = 'updated value'; + } +} diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/list/list.html b/packages/@lwc/integration-karma/test/signal/protocol/x/list/list.html new file mode 100644 index 0000000000..8dc4e54056 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/list/list.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/list/list.js b/packages/@lwc/integration-karma/test/signal/protocol/x/list/list.js new file mode 100644 index 0000000000..2b6333734e --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/list/list.js @@ -0,0 +1,6 @@ +import { LightningElement, api } from 'lwc'; + +export default class extends LightningElement { + @api signal; + items = [1, 2, 3, 4, 5, 6]; +} diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/nonReactive/nonReactive.html b/packages/@lwc/integration-karma/test/signal/protocol/x/nonReactive/nonReactive.html new file mode 100644 index 0000000000..6505517bb7 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/nonReactive/nonReactive.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/nonReactive/nonReactive.js b/packages/@lwc/integration-karma/test/signal/protocol/x/nonReactive/nonReactive.js new file mode 100644 index 0000000000..5face7363b --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/nonReactive/nonReactive.js @@ -0,0 +1,22 @@ +import { LightningElement, api, track } from 'lwc'; +import { Signal } from 'x/signal'; + +const signal = new Signal('initial value'); + +export default class extends LightningElement { + // Note that this signal is bound but it's never referenced on the template + _signal = signal; + @api apiSignalValue = signal.value; + @track trackSignalValue = signal.value; + observedFieldExternalSignalValue = signal.value; + observedFieldBoundSignalValue = this._signal.value; + + get externalSignalValueGetter() { + return signal.value; + } + + @api + getSignalSubscriberCount() { + return signal.getSubscriberCount(); + } +} diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/parent/parent.html b/packages/@lwc/integration-karma/test/signal/protocol/x/parent/parent.html new file mode 100644 index 0000000000..279373129a --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/parent/parent.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/parent/parent.js b/packages/@lwc/integration-karma/test/signal/protocol/x/parent/parent.js new file mode 100644 index 0000000000..c7c7c288c3 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/parent/parent.js @@ -0,0 +1,28 @@ +import { LightningElement, api } from 'lwc'; +import { Signal } from 'x/signal'; + +export default class extends LightningElement { + signal = new Signal('initial value'); + + @api showChild = false; + @api renderCount = 0; + + renderedCallback() { + this.renderCount++; + } + + @api + getSignalSubscriberCount() { + return this.signal.getSubscriberCount(); + } + + @api + getSignalRemovedSubscriberCount() { + return this.signal.getRemovedSubscriberCount(); + } + + @api + updateSignalValue() { + this.signal.value = 'updated value'; + } +} diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/reactive/reactive.html b/packages/@lwc/integration-karma/test/signal/protocol/x/reactive/reactive.html new file mode 100644 index 0000000000..d0dc32ccf4 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/reactive/reactive.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/reactive/reactive.js b/packages/@lwc/integration-karma/test/signal/protocol/x/reactive/reactive.js new file mode 100644 index 0000000000..625b406b0f --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/reactive/reactive.js @@ -0,0 +1,32 @@ +import { LightningElement, api, track } from 'lwc'; +import { Signal } from 'x/signal'; + +const signal = new Signal('initial value'); + +export default class extends LightningElement { + @api showApiSignal = false; + @api showGetterSignal = false; + @api showGetterSignalValue = false; + @api showTrackedSignal = false; + @api showObservedFieldSignal = false; + @api showOnlyUsingSignalNotValue = false; + + @api apiSignal = signal; + @track trackSignal = signal; + + observedFieldSignal = signal; + + get getterSignalField() { + // this works because the signal is bound to the LWC + return this.observedFieldSignal; + } + + get getterSignalFieldValue() { + return this.observedFieldSignal.value; + } + + @api + getSignalSubscriberCount() { + return signal.getSubscriberCount(); + } +} diff --git a/packages/@lwc/integration-karma/test/signal/protocol/x/signal/signal.js b/packages/@lwc/integration-karma/test/signal/protocol/x/signal/signal.js new file mode 100644 index 0000000000..7132f4d9f1 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/x/signal/signal.js @@ -0,0 +1,41 @@ +// Note for testing purposes the signal implementation uses LWC module resolution to simplify things. +// In production the signal will come from a 3rd party library. +export class Signal { + subscribers = new Set(); + removedSubscribers = []; + + constructor(initialValue) { + this._value = initialValue; + } + + set value(newValue) { + this._value = newValue; + this.notify(); + } + + get value() { + return this._value; + } + + subscribe(onUpdate) { + this.subscribers.add(onUpdate); + return () => { + this.subscribers.delete(onUpdate); + this.removedSubscribers.push(onUpdate); + }; + } + + notify() { + for (const subscriber of this.subscribers) { + subscriber(); + } + } + + getSubscriberCount() { + return this.subscribers.size; + } + + getRemovedSubscriberCount() { + return this.removedSubscribers.length; + } +} diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/index.spec.js b/packages/@lwc/integration-karma/test/signal/reactivity/index.spec.js new file mode 100644 index 0000000000..6583037e4b --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/index.spec.js @@ -0,0 +1,105 @@ +import { createElement, setFeatureFlagForTest } from 'lwc'; + +import Reactive from 'x/reactive'; +import NonReactive from 'x/nonReactive'; +import ExplicitSubscribe from 'x/explicitSubscribe'; +import List from 'x/list'; + +// Note for testing purposes the signal implementation uses LWC module resolution to simplify things. +// In production the signal will come from a 3rd party library. +import { Signal } from 'x/signal'; + +const createElementSignalAndInsertIntoDom = async (tagName, ctor, signalInitialValue) => { + const elm = createElement(tagName, { is: ctor }); + const signal = new Signal(signalInitialValue); + elm.signal = signal; + document.body.appendChild(elm); + await Promise.resolve(); + + return { elm, signal }; +}; + +describe('signal reaction in lwc', () => { + beforeAll(() => { + setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', true); + }); + + afterAll(() => { + setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', false); + }); + + it('should render signal value', async () => { + const { elm } = await createElementSignalAndInsertIntoDom( + 'x-reactive', + Reactive, + 'initial value' + ); + + expect(elm.shadowRoot.textContent).toBe('initial value'); + }); + + it('should re-render when signal notification is sent', async () => { + const { elm, signal } = await createElementSignalAndInsertIntoDom( + 'x-reactive', + Reactive, + 'initial value' + ); + + expect(elm.shadowRoot.textContent).toBe('initial value'); + + // notification happens when value is updated + signal.value = 'updated value'; + await Promise.resolve(); + + expect(elm.shadowRoot.textContent).toEqual('updated value'); + }); + + it('does not re-render when signal is not bound to an LWC', async () => { + const elm = createElement('x-non-reactive', { is: NonReactive }); + document.body.appendChild(elm); + await Promise.resolve(); + + expect(elm.shadowRoot.textContent).toBe('external signal value'); + + elm.updateExternalSignal(); + await Promise.resolve(); + + expect(elm.shadowRoot.textContent).toBe('external signal value'); + }); + + it('should be able to re-render when manually subscribing to signal', async () => { + const { elm, signal } = await createElementSignalAndInsertIntoDom( + 'x-manual-subscribe', + ExplicitSubscribe, + 'initial value' + ); + expect(elm.shadowRoot.textContent).toEqual('default'); + + signal.value = 'new value'; + await Promise.resolve(); + + expect(elm.shadowRoot.textContent).toEqual('new value'); + }); + + it('render lists properly', async () => { + const { elm, signal } = await createElementSignalAndInsertIntoDom( + 'x-reactive-list', + List, + [1, 2, 3] + ); + + expect(elm.shadowRoot.children.length).toBe(3); + expect(elm.shadowRoot.children[0].textContent).toBe('1'); + expect(elm.shadowRoot.children[1].textContent).toBe('2'); + expect(elm.shadowRoot.children[2].textContent).toBe('3'); + + signal.value = [3, 2, 1]; + + await Promise.resolve(); + + expect(elm.shadowRoot.children.length).toBe(3); + expect(elm.shadowRoot.children[0].textContent).toBe('3'); + expect(elm.shadowRoot.children[1].textContent).toBe('2'); + expect(elm.shadowRoot.children[2].textContent).toBe('1'); + }); +}); diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/x/explicitSubscribe/explicitSubscribe.html b/packages/@lwc/integration-karma/test/signal/reactivity/x/explicitSubscribe/explicitSubscribe.html new file mode 100644 index 0000000000..6df6f20a58 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/x/explicitSubscribe/explicitSubscribe.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/x/explicitSubscribe/explicitSubscribe.js b/packages/@lwc/integration-karma/test/signal/reactivity/x/explicitSubscribe/explicitSubscribe.js new file mode 100644 index 0000000000..24106084e4 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/x/explicitSubscribe/explicitSubscribe.js @@ -0,0 +1,21 @@ +import { LightningElement, api } from 'lwc'; + +export default class extends LightningElement { + @api signal; + + foo = 'default'; + + signalUnsubscribe = () => {}; + + connectedCallback() { + this.signalUnsubscribe = this.signal.subscribe(() => this.updateOnSignalNotification()); + } + + disconnectedCallback() { + this.signalUnsubscribe(); + } + + updateOnSignalNotification() { + this.foo = this.signal.value; + } +} diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/x/list/list.html b/packages/@lwc/integration-karma/test/signal/reactivity/x/list/list.html new file mode 100644 index 0000000000..51c0f0998b --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/x/list/list.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/x/list/list.js b/packages/@lwc/integration-karma/test/signal/reactivity/x/list/list.js new file mode 100644 index 0000000000..b4ecf8087f --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/x/list/list.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class extends LightningElement { + @api signal; +} diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/x/nonReactive/nonReactive.html b/packages/@lwc/integration-karma/test/signal/reactivity/x/nonReactive/nonReactive.html new file mode 100644 index 0000000000..09ba2ab0bf --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/x/nonReactive/nonReactive.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/x/nonReactive/nonReactive.js b/packages/@lwc/integration-karma/test/signal/reactivity/x/nonReactive/nonReactive.js new file mode 100644 index 0000000000..850199c81a --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/x/nonReactive/nonReactive.js @@ -0,0 +1,15 @@ +import { LightningElement, api } from 'lwc'; +import { Signal } from 'x/signal'; + +const externalSignal = new Signal('external signal value'); + +export default class extends LightningElement { + get bar() { + return externalSignal.value; + } + + @api + updateExternalSignal() { + externalSignal.value = 'updated external value'; + } +} diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/x/reactive/reactive.html b/packages/@lwc/integration-karma/test/signal/reactivity/x/reactive/reactive.html new file mode 100644 index 0000000000..7f27bfba6f --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/x/reactive/reactive.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/x/reactive/reactive.js b/packages/@lwc/integration-karma/test/signal/reactivity/x/reactive/reactive.js new file mode 100644 index 0000000000..41eafcc1a0 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/x/reactive/reactive.js @@ -0,0 +1,6 @@ +import { LightningElement, api } from 'lwc'; + +export default class extends LightningElement { + @api + signal; +} diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/x/reactiveSubscriber/reactiveSubscriber.html b/packages/@lwc/integration-karma/test/signal/reactivity/x/reactiveSubscriber/reactiveSubscriber.html new file mode 100644 index 0000000000..2da7be3d7f --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/x/reactiveSubscriber/reactiveSubscriber.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/x/reactiveSubscriber/reactiveSubscriber.js b/packages/@lwc/integration-karma/test/signal/reactivity/x/reactiveSubscriber/reactiveSubscriber.js new file mode 100644 index 0000000000..41eafcc1a0 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/x/reactiveSubscriber/reactiveSubscriber.js @@ -0,0 +1,6 @@ +import { LightningElement, api } from 'lwc'; + +export default class extends LightningElement { + @api + signal; +} diff --git a/packages/@lwc/integration-karma/test/signal/reactivity/x/signal/signal.js b/packages/@lwc/integration-karma/test/signal/reactivity/x/signal/signal.js new file mode 100644 index 0000000000..168a9aa21c --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/x/signal/signal.js @@ -0,0 +1,35 @@ +// Note for testing purposes the signal implementation uses LWC module resolution to simplify things. +// In production the signal will come from a 3rd party library. +export class Signal { + subscribers = new Set(); + + constructor(initialValue) { + this._value = initialValue; + } + + set value(newValue) { + this._value = newValue; + this.notify(); + } + + get value() { + return this._value; + } + + subscribe(onUpdate) { + this.subscribers.add(onUpdate); + return () => { + this.subscribers.delete(onUpdate); + }; + } + + notify() { + for (const subscriber of this.subscribers) { + subscriber(); + } + } + + getSubscriberCount() { + return this.subscribers.size; + } +} diff --git a/packages/@lwc/signals/README.md b/packages/@lwc/signals/README.md new file mode 100644 index 0000000000..60656175d0 --- /dev/null +++ b/packages/@lwc/signals/README.md @@ -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. + +## 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 + +``` + +## Supported APIs + +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 { + get value(): T; + subscribe(onUpdate: OnUpdate): Unsubscribe; +} +``` + +### SignalBaseClass + +A base class is provided as a starting point for implementation. + +```js +export abstract class SignalBaseClass implements Signal { + abstract get value(): T; + + private subscribers: Set = new Set(); + + subscribe(onUpdate: OnUpdate) { + this.subscribers.add(onUpdate); + return () => { + this.subscribers.delete(onUpdate); + }; + } + + protected notify() { + for (const subscriber of this.subscribers) { + subscriber(); + } + } +} +``` diff --git a/packages/@lwc/signals/jest.config.js b/packages/@lwc/signals/jest.config.js new file mode 100644 index 0000000000..60a3440490 --- /dev/null +++ b/packages/@lwc/signals/jest.config.js @@ -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', +}; diff --git a/packages/@lwc/signals/package.json b/packages/@lwc/signals/package.json new file mode 100644 index 0000000000..c2adca1e23 --- /dev/null +++ b/packages/@lwc/signals/package.json @@ -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 interface to interact with reactivity from outside the framework", + "keywords": [ + "lwc" + ], + "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" + ] + } + } + } +} diff --git a/packages/@lwc/signals/src/__tests__/index.spec.ts b/packages/@lwc/signals/src/__tests__/index.spec.ts new file mode 100644 index 0000000000..a4d8d0ee6f --- /dev/null +++ b/packages/@lwc/signals/src/__tests__/index.spec.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023, 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 { Signal } from './signal'; + +describe('signal protocol', () => { + it('should be able to retrieve value', () => { + const s = new Signal(1); + expect(s.value).toBe(1); + }); + + it('should be able to subscribe to signal', () => { + const s = new Signal(); + expect('subscribe' in s).toBe(true); + expect(typeof s.subscribe).toBe('function'); + const onUpdate = jest.fn(); + expect(() => s.subscribe(onUpdate)).not.toThrow(); + }); + + it('should be able to notify subscribers', () => { + const s = new Signal(); + const onUpdate = jest.fn(); + s.subscribe(onUpdate); + s.value = 1; + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + it('subscribe should return an unsubscribe function', () => { + const s = new Signal(); + const onUpdate = jest.fn(); + const unsubscribe = s.subscribe(onUpdate); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should not notify once unsubscribed', () => { + const s = new Signal(0); + const onUpdate1 = jest.fn(); + const onUpdate2 = jest.fn(); + const unsubscribe1 = s.subscribe(onUpdate1); + const unsubscribe2 = s.subscribe(onUpdate2); + + s.value = 1; + expect(onUpdate1).toHaveBeenCalledTimes(1); + expect(onUpdate2).toHaveBeenCalledTimes(1); + + unsubscribe1(); + + s.value = 2; + expect(onUpdate1).toHaveBeenCalledTimes(1); + expect(onUpdate2).toHaveBeenCalledTimes(2); + + unsubscribe2(); + + s.value = 3; + expect(onUpdate1).toHaveBeenCalledTimes(1); + expect(onUpdate2).toHaveBeenCalledTimes(2); + }); + + it('SignalBaseClass does not subscribe duplicate OnUpdate callback functions', () => { + const s = new Signal(0); + const onUpdate = jest.fn(); + s.subscribe(onUpdate); + s.subscribe(onUpdate); + + s.value = 1; + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + it('should be able to reference other signals in subscription', () => { + const s1 = new Signal(0); + const s2 = new Signal(1); + const s3 = new Signal(1); + + s2.subscribe(() => (s1.value = s2.value + s3.value)); + s3.subscribe(() => (s1.value = s2.value + s3.value)); + + s2.value = 2; + expect(s1.value).toBe(3); + + s3.value = 3; + expect(s1.value).toBe(5); + }); +}); diff --git a/packages/@lwc/signals/src/__tests__/signal.ts b/packages/@lwc/signals/src/__tests__/signal.ts new file mode 100644 index 0000000000..614ac00302 --- /dev/null +++ b/packages/@lwc/signals/src/__tests__/signal.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023, 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 { SignalBaseClass } from '../index'; + +export class Signal extends SignalBaseClass { + _value; + + constructor(initialValue?: any) { + super(); + this._value = initialValue; + } + + set value(newValue) { + this._value = newValue; + this.notify(); + } + + get value() { + return this._value; + } +} diff --git a/packages/@lwc/signals/src/index.ts b/packages/@lwc/signals/src/index.ts new file mode 100644 index 0000000000..aed0332b0a --- /dev/null +++ b/packages/@lwc/signals/src/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023, 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 + */ +export type OnUpdate = () => void; +export type Unsubscribe = () => void; + +export interface Signal { + get value(): T; + subscribe(onUpdate: OnUpdate): Unsubscribe; +} + +export abstract class SignalBaseClass implements Signal { + abstract get value(): T; + + private subscribers: Set = new Set(); + + subscribe(onUpdate: OnUpdate) { + this.subscribers.add(onUpdate); + return () => { + this.subscribers.delete(onUpdate); + }; + } + + protected notify() { + for (const subscriber of this.subscribers) { + subscriber(); + } + } +} diff --git a/packages/@lwc/signals/tsconfig.json b/packages/@lwc/signals/tsconfig.json new file mode 100644 index 0000000000..5f9e0a6df9 --- /dev/null +++ b/packages/@lwc/signals/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + + "compilerOptions": { + "lib": ["es2018"] + }, + + "include": ["src/"] +} diff --git a/scripts/jest/root.config.js b/scripts/jest/root.config.js index bc05bca51b..b69e5198f1 100644 --- a/scripts/jest/root.config.js +++ b/scripts/jest/root.config.js @@ -18,6 +18,7 @@ module.exports = { '/packages/@lwc/module-resolver', '/packages/@lwc/rollup-plugin', '/packages/@lwc/shared', + '/packages/@lwc/signals', '/packages/@lwc/style-compiler', '/packages/@lwc/synthetic-shadow', '/packages/@lwc/template-compiler',