Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: signals test cases #3962

Merged
merged 11 commits into from
Feb 6, 2024
87 changes: 87 additions & 0 deletions packages/@lwc/engine-core/src/libs/signal/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created these tests as a basic sanity check for behavior we're expecting from a signals implementation.

The idea is for anyone planning to implement the interface to understand how the framework expects it to behave.

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);
});
});
26 changes: 26 additions & 0 deletions packages/@lwc/engine-core/src/libs/signal/__tests__/signal.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
_value;

constructor(initialValue?: any) {
super();
this._value = initialValue;
}

set value(newValue) {
this._value = newValue;
this.notify();
}

get value() {
return this._value;
}
}
95 changes: 95 additions & 0 deletions packages/@lwc/integration-karma/test/signal/protocol/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { createElement } from 'lwc';
import Reactive from 'x/reactive';
import NonReactive from 'x/nonReactive';
import Parent from 'x/parent';
import Child from 'x/child';
import { Signal } from 'x/signal';

describe('signal protocol', () => {
describe('lwc engine subscribes template re-render callback when signal is bound to an LWC and used on a template', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests validate when the framework will attempt to subscribe to a signal used in an LWC.

[
{
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 the 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: Parent });
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);

signal.value = 'updated value';

await Promise.resolve();

expect(signal.getSubscriberCount()).toBe(1);
expect(container.renderCount).toBe(1);
expect(signalElm.renderCount).toBe(2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
{signal.value}
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { LightningElement, api } from 'lwc';

export default class extends LightningElement {
@api renderCount = 0;
@api signal;

constructor() {
super();
}

renderedCallback() {
this.renderCount++;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<template>
<span>{apiSignalValue}</span>
<span>{trackSignalValue}</span>
<span>{observedFieldSignalValue}</span>
<span>{externalSignalValueGetter}</span>
<span>{observedFieldBoundSignalValue}</span>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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;
// Note in the tests we use createElement outside of the LWC engine and therefore no template is
// actively rendering which is why this does not get automatically registered.
observedFieldBoundSignalValue = this._signal.value;

get externalSignalValueGetter() {
// Note that the value of the signal is not bound to the class and will therefore not be
// automatically subscribed to the re-render.
return signal.value;
}

@api
getSignalSubscriberCount() {
return signal.getSubscriberCount();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<slot></slot>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LightningElement, api } from 'lwc';

export default class extends LightningElement {
@api renderCount = 0;

renderedCallback() {
this.renderCount++;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<template>
<span lwc:if={showApiSignal}>{apiSignal.value}</span>
<span lwc:if={showGetterSignal}>{getterSignalField.value}</span>
<span lwc:if={showTrackedSignal}>{trackSignal.value}</span>
<span lwc:if={showObservedFieldSignal}>{observedFieldSignal.value}</span>
<span lwc:if={showOnlyUsingSignalNotValue}>{observedFieldSignal}</span>
<span lwc:if={showGetterSignalValue}>{getterSignalFieldValue}</span>
</template>
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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;
}
}
Loading