Skip to content

Commit 314ab66

Browse files
authored
feat: signals implementation v1 (#3963)
1 parent c722946 commit 314ab66

File tree

47 files changed

+1089
-14
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1089
-14
lines changed

packages/@lwc/engine-core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@lwc/shared": "6.1.1"
4747
},
4848
"devDependencies": {
49-
"observable-membrane": "2.0.0"
49+
"observable-membrane": "2.0.0",
50+
"@lwc/signals": "6.0.0"
5051
}
5152
}

packages/@lwc/engine-core/src/framework/component.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import {
1313
LOWEST_API_VERSION,
1414
} from '@lwc/shared';
1515

16-
import { createReactiveObserver, ReactiveObserver } from './mutation-tracker';
16+
import {
17+
createReactiveObserver,
18+
ReactiveObserver,
19+
unsubscribeFromSignals,
20+
} from './mutation-tracker';
1721

1822
import { invokeComponentRenderMethod, isInvokingRender, invokeEventListener } from './invoker';
1923
import { VM, scheduleRehydration } from './vm';
@@ -86,12 +90,28 @@ export function getTemplateReactiveObserver(vm: VM): ReactiveObserver {
8690
});
8791
}
8892

93+
export function resetTemplateObserverAndUnsubscribe(vm: VM) {
94+
const { tro, component } = vm;
95+
tro.reset();
96+
// Unsubscribe every time the template reactive observer is reset.
97+
if (lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS) {
98+
unsubscribeFromSignals(component);
99+
}
100+
}
101+
89102
export function renderComponent(vm: VM): VNodes {
90103
if (process.env.NODE_ENV !== 'production') {
91104
assert.invariant(vm.isDirty, `${vm} is not dirty.`);
92105
}
93-
94-
vm.tro.reset();
106+
// The engine should only hold a subscription to a signal if it is rendered in the template.
107+
// Because of the potential presence of conditional rendering logic, we unsubscribe on each render
108+
// in the scenario where it is present in one condition but not the other.
109+
// For example:
110+
// 1. There is an lwc:if=true conditional where the signal is present on the template.
111+
// 2. The lwc:if changes to false and the signal is no longer present on the template.
112+
// If the signal is still subscribed to, the template will re-render when it receives a notification
113+
// from the signal, even though we won't be using the new value.
114+
resetTemplateObserverAndUnsubscribe(vm);
95115
const vnodes = invokeComponentRenderMethod(vm);
96116
vm.isDirty = false;
97117
vm.isScheduled = false;

packages/@lwc/engine-core/src/framework/decorators/api.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ export function createPublicPropertyDescriptor(key: string): PropertyDescriptor
4040
}
4141
return;
4242
}
43-
componentValueObserved(vm, key);
44-
return vm.cmpProps[key];
43+
const val = vm.cmpProps[key];
44+
componentValueObserved(vm, key, val);
45+
return val;
4546
},
4647
set(this: LightningElement, newValue: any) {
4748
const vm = getAssociatedVM(this);

packages/@lwc/engine-core/src/framework/decorators/track.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ export function internalTrackDecorator(key: string): PropertyDescriptor {
4040
return {
4141
get(this: LightningElement): any {
4242
const vm = getAssociatedVM(this);
43-
componentValueObserved(vm, key);
44-
return vm.cmpFields[key];
43+
const val = vm.cmpFields[key];
44+
componentValueObserved(vm, key, val);
45+
return val;
4546
},
4647
set(this: LightningElement, newValue: any) {
4748
const vm = getAssociatedVM(this);

packages/@lwc/engine-core/src/framework/mutation-tracker.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
* SPDX-License-Identifier: MIT
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
66
*/
7+
import { isFunction, isNull, isObject } from '@lwc/shared';
8+
import { Signal } from '@lwc/signals';
79
import {
810
JobFunction,
911
CallbackFunction,
1012
ReactiveObserver,
1113
valueMutated,
1214
valueObserved,
1315
} from '../libs/mutation-tracker';
16+
import { subscribeToSignal } from '../libs/signal-tracker';
1417
import { VM } from './vm';
1518

1619
const DUMMY_REACTIVE_OBSERVER = {
@@ -28,10 +31,29 @@ export function componentValueMutated(vm: VM, key: PropertyKey) {
2831
}
2932
}
3033

31-
export function componentValueObserved(vm: VM, key: PropertyKey) {
34+
export function componentValueObserved(vm: VM, key: PropertyKey, target: any = {}) {
35+
const { component, tro } = vm;
3236
// On the server side, we don't need mutation tracking. Skipping it improves performance.
3337
if (process.env.IS_BROWSER) {
34-
valueObserved(vm.component, key);
38+
valueObserved(component, key);
39+
}
40+
41+
// The portion of reactivity that's exposed to signals is to subscribe a callback to re-render the VM (templates).
42+
// We check check the following to ensure re-render is subscribed at the correct time.
43+
// 1. The template is currently being rendered (there is a template reactive observer)
44+
// 2. There was a call to a getter to access the signal (happens during vnode generation)
45+
if (
46+
lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS &&
47+
isObject(target) &&
48+
!isNull(target) &&
49+
'value' in target &&
50+
'subscribe' in target &&
51+
isFunction(target.subscribe) &&
52+
// Only subscribe if a template is being rendered by the engine
53+
tro.isObserving()
54+
) {
55+
// Subscribe the template reactive observer's notify method, which will mark the vm as dirty and schedule hydration.
56+
subscribeToSignal(component, target as Signal<any>, tro.notify.bind(tro));
3557
}
3658
}
3759

@@ -41,3 +63,4 @@ export function createReactiveObserver(callback: CallbackFunction): ReactiveObse
4163
}
4264

4365
export * from '../libs/mutation-tracker';
66+
export * from '../libs/signal-tracker';

packages/@lwc/engine-core/src/framework/observed-fields.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ export function createObservedFieldPropertyDescriptor(key: string): PropertyDesc
1313
return {
1414
get(this: LightningElement): any {
1515
const vm = getAssociatedVM(this);
16-
componentValueObserved(vm, key);
17-
return vm.cmpFields[key];
16+
const val = vm.cmpFields[key];
17+
componentValueObserved(vm, key, val);
18+
return val;
1819
},
1920
set(this: LightningElement, newValue: any) {
2021
const vm = getAssociatedVM(this);

packages/@lwc/engine-core/src/framework/vm.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
markComponentAsDirty,
3232
getTemplateReactiveObserver,
3333
getComponentAPIVersion,
34+
resetTemplateObserverAndUnsubscribe,
3435
} from './component';
3536
import {
3637
addCallbackToNextTick,
@@ -272,9 +273,8 @@ function resetComponentStateWhenRemoved(vm: VM) {
272273
const { state } = vm;
273274

274275
if (state !== VMState.disconnected) {
275-
const { tro } = vm;
276276
// Making sure that any observing record will not trigger the rehydrated on this vm
277-
tro.reset();
277+
resetTemplateObserverAndUnsubscribe(vm);
278278
runDisconnectedCallback(vm);
279279
// Spec: https://dom.spec.whatwg.org/#concept-node-remove (step 14-15)
280280
runChildNodesDisconnectedCallback(vm);

packages/@lwc/engine-core/src/libs/mutation-tracker/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,8 @@ export class ReactiveObserver {
125125
// we keep track of observing records where the observing record was added to so we can do some clean up later on
126126
ArrayPush.call(this.listeners, reactiveObservers);
127127
}
128+
129+
isObserving() {
130+
return currentReactiveObserver === this;
131+
}
128132
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright (c) 2024, salesforce.com, inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: MIT
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
6+
*/
7+
import { isFalse, isFunction, isUndefined } from '@lwc/shared';
8+
import { Signal } from '@lwc/signals';
9+
import { logWarnOnce } from '../../shared/logger';
10+
11+
/**
12+
* This map keeps track of objects to signals. There is an assumption that the signal is strongly referenced
13+
* on the object which allows the SignalTracker to be garbage collected along with the object.
14+
*/
15+
const TargetToSignalTrackerMap = new WeakMap<object, SignalTracker>();
16+
17+
function getSignalTracker(target: object) {
18+
let signalTracker = TargetToSignalTrackerMap.get(target);
19+
if (isUndefined(signalTracker)) {
20+
signalTracker = new SignalTracker();
21+
TargetToSignalTrackerMap.set(target, signalTracker);
22+
}
23+
return signalTracker;
24+
}
25+
26+
export function subscribeToSignal(
27+
target: Object,
28+
signal: Signal<unknown>,
29+
update: CallbackFunction
30+
) {
31+
const signalTracker = getSignalTracker(target);
32+
if (isFalse(signalTracker.seen(signal))) {
33+
signalTracker.subscribeToSignal(signal, update);
34+
}
35+
}
36+
37+
export function unsubscribeFromSignals(target: object) {
38+
if (TargetToSignalTrackerMap.has(target)) {
39+
const signalTracker = getSignalTracker(target);
40+
signalTracker.unsubscribeFromSignals();
41+
signalTracker.reset();
42+
}
43+
}
44+
45+
type CallbackFunction = () => void;
46+
47+
/**
48+
* This class is used to keep track of the signals associated to a given object.
49+
* It is used to prevent the LWC engine from subscribing duplicate callbacks multiple times
50+
* to the same signal. Additionally, it keeps track of all signal unsubscribe callbacks, handles invoking
51+
* them when necessary and discarding them.
52+
*/
53+
class SignalTracker {
54+
private signalToUnsubscribeMap: Map<Signal<unknown>, CallbackFunction> = new Map();
55+
56+
seen(signal: Signal<unknown>) {
57+
return this.signalToUnsubscribeMap.has(signal);
58+
}
59+
60+
subscribeToSignal(signal: Signal<unknown>, update: CallbackFunction) {
61+
try {
62+
const unsubscribe = signal.subscribe(update);
63+
if (isFunction(unsubscribe)) {
64+
// TODO [#3978]: Evaluate how we should handle the case when unsubscribe is not a function.
65+
// Long term we should throw an error or log a warning.
66+
this.signalToUnsubscribeMap.set(signal, unsubscribe);
67+
}
68+
} catch (err: any) {
69+
logWarnOnce(
70+
`Attempted to subscribe to an object that has the shape of a signal but received the following error: ${
71+
err?.stack ?? err
72+
}`
73+
);
74+
}
75+
}
76+
77+
unsubscribeFromSignals() {
78+
try {
79+
this.signalToUnsubscribeMap.forEach((unsubscribe) => unsubscribe());
80+
} catch (err: any) {
81+
logWarnOnce(
82+
`Attempted to call a signal's unsubscribe callback but received the following error: ${
83+
err?.stack ?? err
84+
}`
85+
);
86+
}
87+
}
88+
89+
reset() {
90+
this.signalToUnsubscribeMap.clear();
91+
}
92+
}

packages/@lwc/features/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const features: FeatureFlagMap = {
1818
ENABLE_FROZEN_TEMPLATE: null,
1919
ENABLE_LEGACY_SCOPE_TOKENS: null,
2020
ENABLE_FORCE_SHADOW_MIGRATE_MODE: null,
21+
ENABLE_EXPERIMENTAL_SIGNALS: null,
2122
DISABLE_TEMPORARY_V5_COMPILER_SUPPORT: null,
2223
};
2324

0 commit comments

Comments
 (0)