diff --git a/packages/@lwc/engine-core/src/framework/mutation-tracker.ts b/packages/@lwc/engine-core/src/framework/mutation-tracker.ts index d28bd0225e..11f4cb6832 100644 --- a/packages/@lwc/engine-core/src/framework/mutation-tracker.ts +++ b/packages/@lwc/engine-core/src/framework/mutation-tracker.ts @@ -5,9 +5,9 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ import { isNull, isObject, isTrustedSignal, legacyIsTrustedSignal } from '@lwc/shared'; +import { type Signal, isLwcSignal } from '@lwc/signals'; import { ReactiveObserver, valueMutated, valueObserved } from '../libs/mutation-tracker'; import { subscribeToSignal } from '../libs/signal-tracker'; -import type { Signal } from '@lwc/signals'; import type { JobFunction, CallbackFunction } from '../libs/mutation-tracker'; import type { VM } from './vm'; @@ -45,6 +45,9 @@ export function componentValueObserved(vm: VM, key: PropertyKey, target: any = { // Only subscribe if a template is being rendered by the engine tro.isObserving() ) { + // TODO [#123]: add gate + const lwcSignal = (target as any)[isLwcSignal]; + /** * The legacy validation behavior was that this check should only * be performed for runtimes that have provided a trustedSignals set. @@ -53,11 +56,11 @@ export function componentValueObserved(vm: VM, key: PropertyKey, target: any = { * set had not been defined. The runtime flag has been added as a killswitch * in case the fix needs to be reverted. */ - if ( - lwcRuntimeFlags.ENABLE_LEGACY_SIGNAL_CONTEXT_VALIDATION - ? legacyIsTrustedSignal(target) - : isTrustedSignal(target) - ) { + const trustedSignal = lwcRuntimeFlags.ENABLE_LEGACY_SIGNAL_CONTEXT_VALIDATION + ? legacyIsTrustedSignal(target) + : isTrustedSignal(target); + + if (lwcSignal || trustedSignal) { // 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)); } diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/index.spec.js b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/index.spec.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/index.spec.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/index.spec.js diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/child/child.html b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/child/child.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/child/child.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/child/child.html diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/child/child.js b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/child/child.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/child/child.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/child/child.js diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/container/container.html b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/container/container.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/container/container.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/container/container.html diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/container/container.js b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/container/container.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/container/container.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/container/container.js diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.html b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.html diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.js b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.js diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/list/list.html b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/list/list.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/list/list.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/list/list.html diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/list/list.js b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/list/list.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/list/list.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/list/list.js diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/nonReactive/nonReactive.html b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/nonReactive/nonReactive.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/nonReactive/nonReactive.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/nonReactive/nonReactive.html diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/nonReactive/nonReactive.js b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/nonReactive/nonReactive.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/nonReactive/nonReactive.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/nonReactive/nonReactive.js diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/parent/parent.html b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/parent/parent.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/parent/parent.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/parent/parent.html diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/parent/parent.js b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/parent/parent.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/parent/parent.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/parent/parent.js diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/reactive/reactive.html b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/reactive/reactive.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/reactive/reactive.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/reactive/reactive.html diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/reactive/reactive.js b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/reactive/reactive.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/reactive/reactive.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/reactive/reactive.js diff --git a/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/signal/signal.js b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/signal/signal.js new file mode 100644 index 0000000000..479e5f64ad --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/signal/signal.js @@ -0,0 +1,38 @@ +// 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 { SignalBaseClass } from 'lwc'; + +export class Signal extends SignalBaseClass { + removedSubscribers = []; + + constructor(initialValue) { + super(); + 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); + }; + } + + getSubscriberCount() { + return this.subscribers.size; + } + + getRemovedSubscriberCount() { + return this.removedSubscribers.length; + } +} diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/throws/throws.html b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/throws/throws.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/throws/throws.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/throws/throws.html diff --git a/packages/@lwc/integration-not-karma/test/signal/protocol/x/throws/throws.js b/packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/throws/throws.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/throws/throws.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/protocol/x/throws/throws.js diff --git a/packages/@lwc/integration-not-karma/test/signal/reactivity/index.spec.js b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/index.spec.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/index.spec.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/index.spec.js diff --git a/packages/@lwc/integration-not-karma/test/signal/reactivity/x/explicitSubscribe/explicitSubscribe.html b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/explicitSubscribe/explicitSubscribe.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/x/explicitSubscribe/explicitSubscribe.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/explicitSubscribe/explicitSubscribe.html diff --git a/packages/@lwc/integration-not-karma/test/signal/reactivity/x/explicitSubscribe/explicitSubscribe.js b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/explicitSubscribe/explicitSubscribe.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/x/explicitSubscribe/explicitSubscribe.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/explicitSubscribe/explicitSubscribe.js diff --git a/packages/@lwc/integration-not-karma/test/signal/reactivity/x/list/list.html b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/list/list.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/x/list/list.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/list/list.html diff --git a/packages/@lwc/integration-not-karma/test/signal/reactivity/x/list/list.js b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/list/list.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/x/list/list.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/list/list.js diff --git a/packages/@lwc/integration-not-karma/test/signal/reactivity/x/nonReactive/nonReactive.html b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/nonReactive/nonReactive.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/x/nonReactive/nonReactive.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/nonReactive/nonReactive.html diff --git a/packages/@lwc/integration-not-karma/test/signal/reactivity/x/nonReactive/nonReactive.js b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/nonReactive/nonReactive.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/x/nonReactive/nonReactive.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/nonReactive/nonReactive.js diff --git a/packages/@lwc/integration-not-karma/test/signal/reactivity/x/reactive/reactive.html b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/reactive/reactive.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/x/reactive/reactive.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/reactive/reactive.html diff --git a/packages/@lwc/integration-not-karma/test/signal/reactivity/x/reactive/reactive.js b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/reactive/reactive.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/x/reactive/reactive.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/reactive/reactive.js diff --git a/packages/@lwc/integration-not-karma/test/signal/reactivity/x/reactiveSubscriber/reactiveSubscriber.html b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/reactiveSubscriber/reactiveSubscriber.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/x/reactiveSubscriber/reactiveSubscriber.html rename to packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/reactiveSubscriber/reactiveSubscriber.html diff --git a/packages/@lwc/integration-not-karma/test/signal/reactivity/x/reactiveSubscriber/reactiveSubscriber.js b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/reactiveSubscriber/reactiveSubscriber.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/x/reactiveSubscriber/reactiveSubscriber.js rename to packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/reactiveSubscriber/reactiveSubscriber.js diff --git a/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/signal/signal.js b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/signal/signal.js new file mode 100644 index 0000000000..ba9551c713 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-lwc/reactivity/x/signal/signal.js @@ -0,0 +1,24 @@ +// 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 { SignalBaseClass } from 'lwc'; + +export class Signal extends SignalBaseClass { + constructor(initialValue) { + super(); + this.value = initialValue; + } + + set value(newValue) { + this._value = newValue; + this.notify(); + } + + get value() { + return this._value; + } + + getSubscriberCount() { + return this.subscribers.size; + } +} diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/index.spec.js b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/index.spec.js new file mode 100644 index 0000000000..ebd82e0e4f --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/index.spec.js @@ -0,0 +1,252 @@ +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'; +import Throws from 'x/throws'; + +// 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'; +import { fn as mockFn } from '@vitest/spy'; +import { resetDOM } from '../../../helpers/reset.js'; + +describe('signal protocol', () => { + beforeAll(() => { + setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', true); + }); + + afterAll(() => { + setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', false); + }); + + afterEach(resetDOM); + + 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 = mockFn(); + // 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(); + }); + + it('does not subscribe if the signal is not added as trusted signal', async () => { + const elm = createElement('x-child', { is: Child }); + const subscribe = mockFn(); + // Note this follows the shape of the signal implementation + // but it's not added as a trusted signal (add using lwc.addTrustedSignal) + const signal = { + get value() { + return 'initial value'; + }, + subscribe, + }; + elm.signal = signal; + document.body.appendChild(elm); + await Promise.resolve(); + + expect(subscribe).not.toHaveBeenCalled(); + }); + + it('does not throw an error for objects that throw upon "in" checks', async () => { + const elm = createElement('x-throws', { is: Throws }); + document.body.appendChild(elm); + + await Promise.resolve(); + + expect(elm.shadowRoot.querySelector('h1').textContent).toBe('hello'); + }); +}); + +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-not-karma/test/signal-trusted/protocol/x/child/child.html b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/child/child.html new file mode 100644 index 0000000000..2428dd9e7c --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/child/child.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/child/child.js b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/child/child.js new file mode 100644 index 0000000000..76a657a00f --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal-trusted/protocol/x/container/container.html b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/container/container.html new file mode 100644 index 0000000000..fba6288c0b --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/container/container.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/container/container.js b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/container/container.js new file mode 100644 index 0000000000..dbb973cc07 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal-trusted/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.html b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.html new file mode 100644 index 0000000000..9e7f95c88d --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.js b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/duplicateSignalOnTemplate/duplicateSignalOnTemplate.js new file mode 100644 index 0000000000..67f2d836e1 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal-trusted/protocol/x/list/list.html b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/list/list.html new file mode 100644 index 0000000000..8dc4e54056 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/list/list.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/list/list.js b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/list/list.js new file mode 100644 index 0000000000..2b6333734e --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal-trusted/protocol/x/nonReactive/nonReactive.html b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/nonReactive/nonReactive.html new file mode 100644 index 0000000000..6505517bb7 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/nonReactive/nonReactive.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/nonReactive/nonReactive.js b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/nonReactive/nonReactive.js new file mode 100644 index 0000000000..5face7363b --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal-trusted/protocol/x/parent/parent.html b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/parent/parent.html new file mode 100644 index 0000000000..279373129a --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/parent/parent.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/parent/parent.js b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/parent/parent.js new file mode 100644 index 0000000000..c7c7c288c3 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal-trusted/protocol/x/reactive/reactive.html b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/reactive/reactive.html new file mode 100644 index 0000000000..d0dc32ccf4 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/reactive/reactive.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/reactive/reactive.js b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/reactive/reactive.js new file mode 100644 index 0000000000..625b406b0f --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal/protocol/x/signal/signal.js b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/signal/signal.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/protocol/x/signal/signal.js rename to packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/signal/signal.js diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/throws/throws.html b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/throws/throws.html new file mode 100644 index 0000000000..e40c3bd251 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/throws/throws.html @@ -0,0 +1,3 @@ + diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/throws/throws.js b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/throws/throws.js new file mode 100644 index 0000000000..21f0811441 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/protocol/x/throws/throws.js @@ -0,0 +1,23 @@ +import { LightningElement } from 'lwc'; + +export default class extends LightningElement { + foo; + + constructor() { + super(); + + this.foo = new Proxy( + {}, + { + has() { + throw new Error("oh no you don't!"); + }, + } + ); + } + + renderedCallback() { + // access `this.foo` to trigger mutation-tracker.ts + this.bar = this.foo; + } +} diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/index.spec.js b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/index.spec.js new file mode 100644 index 0000000000..6583037e4b --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal-trusted/reactivity/x/explicitSubscribe/explicitSubscribe.html b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/explicitSubscribe/explicitSubscribe.html new file mode 100644 index 0000000000..6df6f20a58 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/explicitSubscribe/explicitSubscribe.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/explicitSubscribe/explicitSubscribe.js b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/explicitSubscribe/explicitSubscribe.js new file mode 100644 index 0000000000..24106084e4 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal-trusted/reactivity/x/list/list.html b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/list/list.html new file mode 100644 index 0000000000..51c0f0998b --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/list/list.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/list/list.js b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/list/list.js new file mode 100644 index 0000000000..b4ecf8087f --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal-trusted/reactivity/x/nonReactive/nonReactive.html b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/nonReactive/nonReactive.html new file mode 100644 index 0000000000..09ba2ab0bf --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/nonReactive/nonReactive.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/nonReactive/nonReactive.js b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/nonReactive/nonReactive.js new file mode 100644 index 0000000000..850199c81a --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal-trusted/reactivity/x/reactive/reactive.html b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/reactive/reactive.html new file mode 100644 index 0000000000..7f27bfba6f --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/reactive/reactive.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/reactive/reactive.js b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/reactive/reactive.js new file mode 100644 index 0000000000..41eafcc1a0 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal-trusted/reactivity/x/reactiveSubscriber/reactiveSubscriber.html b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/reactiveSubscriber/reactiveSubscriber.html new file mode 100644 index 0000000000..2da7be3d7f --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/reactiveSubscriber/reactiveSubscriber.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/reactiveSubscriber/reactiveSubscriber.js b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/reactiveSubscriber/reactiveSubscriber.js new file mode 100644 index 0000000000..41eafcc1a0 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/signal-trusted/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-not-karma/test/signal/reactivity/x/signal/signal.js b/packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/signal/signal.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/reactivity/x/signal/signal.js rename to packages/@lwc/integration-not-karma/test/signal-trusted/reactivity/x/signal/signal.js diff --git a/packages/@lwc/integration-not-karma/test/signal/untrusted/index.spec.js b/packages/@lwc/integration-not-karma/test/signal-trusted/untrusted/index.spec.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/untrusted/index.spec.js rename to packages/@lwc/integration-not-karma/test/signal-trusted/untrusted/index.spec.js diff --git a/packages/@lwc/integration-not-karma/test/signal/untrusted/x/signal/signal.js b/packages/@lwc/integration-not-karma/test/signal-trusted/untrusted/x/signal/signal.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/untrusted/x/signal/signal.js rename to packages/@lwc/integration-not-karma/test/signal-trusted/untrusted/x/signal/signal.js diff --git a/packages/@lwc/integration-not-karma/test/signal/untrusted/x/test/test.html b/packages/@lwc/integration-not-karma/test/signal-trusted/untrusted/x/test/test.html similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/untrusted/x/test/test.html rename to packages/@lwc/integration-not-karma/test/signal-trusted/untrusted/x/test/test.html diff --git a/packages/@lwc/integration-not-karma/test/signal/untrusted/x/test/test.js b/packages/@lwc/integration-not-karma/test/signal-trusted/untrusted/x/test/test.js similarity index 100% rename from packages/@lwc/integration-not-karma/test/signal/untrusted/x/test/test.js rename to packages/@lwc/integration-not-karma/test/signal-trusted/untrusted/x/test/test.js diff --git a/packages/@lwc/signals/src/index.ts b/packages/@lwc/signals/src/index.ts index 1b7ffed106..aedfecf070 100644 --- a/packages/@lwc/signals/src/index.ts +++ b/packages/@lwc/signals/src/index.ts @@ -16,7 +16,11 @@ export interface Signal { subscribe(onUpdate: OnUpdate): Unsubscribe; } +export const isLwcSignal = Symbol('lwcSignal'); + export abstract class SignalBaseClass implements Signal { + [isLwcSignal] = true; + constructor() { // Add the signal to the set of trusted signals // that rendering engine can track