Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/karma.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/@lwc/integration-karma/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ This set of environment variables applies to the `start` and `test` commands:
- **`API_VERSION=<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

Expand Down
2 changes: 2 additions & 0 deletions packages/@lwc/integration-karma/scripts/karma-plugins/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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)},
Expand Down
2 changes: 2 additions & 0 deletions packages/@lwc/integration-karma/scripts/shared/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down
223 changes: 223 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,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);
});
});
}
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,10 @@
import { LightningElement, api } from 'lwc';

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

renderedCallback() {
this.renderCount++;
}
}
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,5 @@
<template>
{signal.value}
{signal.value}
{signal.value}
</template>
Original file line number Diff line number Diff line change
@@ -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';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<template for:each={items} for:item="item">
<p key={item}>{signal.value}</p>
</template>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { LightningElement, api } from 'lwc';

export default class extends LightningElement {
@api signal;
items = [1, 2, 3, 4, 5, 6];
}
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,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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<x-child lwc:if={showChild} signal={signal}></x-child>
</template>
Loading