diff --git a/.github/workflows/karma.yml b/.github/workflows/karma.yml index aad1808cb4..29b7a5a3e2 100644 --- a/.github/workflows/karma.yml +++ b/.github/workflows/karma.yml @@ -59,6 +59,8 @@ jobs: - run: API_VERSION=58 DISABLE_SYNTHETIC=1 yarn sauce:ci - run: API_VERSION=59 yarn sauce:ci - run: API_VERSION=59 DISABLE_SYNTHETIC=1 yarn sauce:ci + - run: ENABLE_EXPERIMENTAL_SIGNALS=1 yarn sauce:ci + - run: ENABLE_EXPERIMENTAL_SIGNALS=1 DISABLE_SYNTHETIC=1 yarn sauce:ci - name: Upload coverage results uses: actions/upload-artifact@v3 diff --git a/packages/@lwc/integration-karma/README.md b/packages/@lwc/integration-karma/README.md index f22a5f7243..9499fc75e9 100644 --- a/packages/@lwc/integration-karma/README.md +++ b/packages/@lwc/integration-karma/README.md @@ -40,6 +40,7 @@ This set of environment variables applies to the `start` and `test` commands: - **`API_VERSION=`:** API version to use when compiling. - **`DISABLE_SYNTHETIC_SHADOW_SUPPORT_IN_COMPILER=1`:** Disable synthetic shadow in the compiler itself. - **`DISABLE_STATIC_CONTENT_OPTIMIZATION=1`:** Disable static content optimization by setting `enableStaticContentOptimization` to `false`. +- **`ENABLE_EXPERIMENTAL_SIGNALS=1`:** Enables tests for experimental signals protocol. ## Examples diff --git a/packages/@lwc/integration-karma/scripts/karma-plugins/env.js b/packages/@lwc/integration-karma/scripts/karma-plugins/env.js index 2f21901cbb..76005ccfeb 100644 --- a/packages/@lwc/integration-karma/scripts/karma-plugins/env.js +++ b/packages/@lwc/integration-karma/scripts/karma-plugins/env.js @@ -22,6 +22,7 @@ const { ENABLE_SYNTHETIC_SHADOW_IN_HYDRATION, NODE_ENV_FOR_TEST, API_VERSION, + ENABLE_EXPERIMENTAL_SIGNALS, FORCE_LWC_V5_COMPILER_FOR_TEST, } = require('../shared/options'); @@ -47,6 +48,7 @@ function createEnvFile() { NATIVE_SHADOW_ROOT_DEFINED: typeof ShadowRoot !== 'undefined', SYNTHETIC_SHADOW_ENABLED: ${SYNTHETIC_SHADOW_ENABLED}, ENABLE_ARIA_REFLECTION_GLOBAL_POLYFILL: ${ENABLE_ARIA_REFLECTION_GLOBAL_POLYFILL}, + ENABLE_EXPERIMENTAL_SIGNALS: ${ENABLE_EXPERIMENTAL_SIGNALS}, ENABLE_SYNTHETIC_SHADOW_IN_HYDRATION: ${ENABLE_SYNTHETIC_SHADOW_IN_HYDRATION}, LWC_VERSION: ${JSON.stringify(LWC_VERSION)}, API_VERSION: ${JSON.stringify(API_VERSION)}, diff --git a/packages/@lwc/integration-karma/scripts/shared/options.js b/packages/@lwc/integration-karma/scripts/shared/options.js index d1c9a4c23a..337d7085a4 100644 --- a/packages/@lwc/integration-karma/scripts/shared/options.js +++ b/packages/@lwc/integration-karma/scripts/shared/options.js @@ -33,6 +33,7 @@ const NODE_ENV_FOR_TEST = process.env.NODE_ENV_FOR_TEST; const API_VERSION = process.env.API_VERSION ? parseInt(process.env.API_VERSION, 10) : HIGHEST_API_VERSION; +const ENABLE_EXPERIMENTAL_SIGNALS = Boolean(process.env.ENABLE_EXPERIMENTAL_SIGNALS); // TODO [#3974]: remove temporary logic to support v5 compiler + v6+ engine const FORCE_LWC_V5_COMPILER_FOR_TEST = Boolean(process.env.FORCE_LWC_V5_COMPILER_FOR_TEST); @@ -46,6 +47,7 @@ module.exports = { DISABLE_SYNTHETIC_SHADOW_SUPPORT_IN_COMPILER, DISABLE_STATIC_CONTENT_OPTIMIZATION, SYNTHETIC_SHADOW_ENABLED: !DISABLE_SYNTHETIC, + ENABLE_EXPERIMENTAL_SIGNALS, API_VERSION, FORCE_LWC_V5_COMPILER_FOR_TEST, ENABLE_SYNTHETIC_SHADOW_IN_HYDRATION, 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..bdac7497b7 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/protocol/index.spec.js @@ -0,0 +1,223 @@ +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'; + +if (process.env.ENABLE_EXPERIMENTAL_SIGNALS) { + 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 }); + const signalElm = createElement('x-signal-elm', { is: Child }); + const signal = new Signal('initial value'); + signalElm.signal = signal; + container.appendChild(signalElm); + document.body.appendChild(container); + + 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(); + }); + }); +} else { + 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..9e5f6ac4a8 --- /dev/null +++ b/packages/@lwc/integration-karma/test/signal/reactivity/index.spec.js @@ -0,0 +1,107 @@ +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'; + +if (process.env.ENABLE_EXPERIMENTAL_SIGNALS) { + 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; + } +}