Skip to content

Commit

Permalink
feat: signals implementation v1 (#3963)
Browse files Browse the repository at this point in the history
  • Loading branch information
jmsjtu authored Feb 7, 2024
1 parent c722946 commit 314ab66
Show file tree
Hide file tree
Showing 47 changed files with 1,089 additions and 14 deletions.
3 changes: 2 additions & 1 deletion packages/@lwc/engine-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@lwc/shared": "6.1.1"
},
"devDependencies": {
"observable-membrane": "2.0.0"
"observable-membrane": "2.0.0",
"@lwc/signals": "6.0.0"
}
}
26 changes: 23 additions & 3 deletions packages/@lwc/engine-core/src/framework/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {
LOWEST_API_VERSION,
} from '@lwc/shared';

import { createReactiveObserver, ReactiveObserver } from './mutation-tracker';
import {
createReactiveObserver,
ReactiveObserver,
unsubscribeFromSignals,
} from './mutation-tracker';

import { invokeComponentRenderMethod, isInvokingRender, invokeEventListener } from './invoker';
import { VM, scheduleRehydration } from './vm';
Expand Down Expand Up @@ -86,12 +90,28 @@ export function getTemplateReactiveObserver(vm: VM): ReactiveObserver {
});
}

export function resetTemplateObserverAndUnsubscribe(vm: VM) {
const { tro, component } = vm;
tro.reset();
// Unsubscribe every time the template reactive observer is reset.
if (lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS) {
unsubscribeFromSignals(component);
}
}

export function renderComponent(vm: VM): VNodes {
if (process.env.NODE_ENV !== 'production') {
assert.invariant(vm.isDirty, `${vm} is not dirty.`);
}

vm.tro.reset();
// The engine should only hold a subscription to a signal if it is rendered in the template.
// Because of the potential presence of conditional rendering logic, we unsubscribe on each render
// in the scenario where it is present in one condition but not the other.
// For example:
// 1. There is an lwc:if=true conditional where the signal is present on the template.
// 2. The lwc:if changes to false and the signal is no longer present on the template.
// If the signal is still subscribed to, the template will re-render when it receives a notification
// from the signal, even though we won't be using the new value.
resetTemplateObserverAndUnsubscribe(vm);
const vnodes = invokeComponentRenderMethod(vm);
vm.isDirty = false;
vm.isScheduled = false;
Expand Down
5 changes: 3 additions & 2 deletions packages/@lwc/engine-core/src/framework/decorators/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ export function createPublicPropertyDescriptor(key: string): PropertyDescriptor
}
return;
}
componentValueObserved(vm, key);
return vm.cmpProps[key];
const val = vm.cmpProps[key];
componentValueObserved(vm, key, val);
return val;
},
set(this: LightningElement, newValue: any) {
const vm = getAssociatedVM(this);
Expand Down
5 changes: 3 additions & 2 deletions packages/@lwc/engine-core/src/framework/decorators/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ export function internalTrackDecorator(key: string): PropertyDescriptor {
return {
get(this: LightningElement): any {
const vm = getAssociatedVM(this);
componentValueObserved(vm, key);
return vm.cmpFields[key];
const val = vm.cmpFields[key];
componentValueObserved(vm, key, val);
return val;
},
set(this: LightningElement, newValue: any) {
const vm = getAssociatedVM(this);
Expand Down
27 changes: 25 additions & 2 deletions packages/@lwc/engine-core/src/framework/mutation-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import { isFunction, isNull, isObject } from '@lwc/shared';
import { Signal } from '@lwc/signals';
import {
JobFunction,
CallbackFunction,
ReactiveObserver,
valueMutated,
valueObserved,
} from '../libs/mutation-tracker';
import { subscribeToSignal } from '../libs/signal-tracker';
import { VM } from './vm';

const DUMMY_REACTIVE_OBSERVER = {
Expand All @@ -28,10 +31,29 @@ export function componentValueMutated(vm: VM, key: PropertyKey) {
}
}

export function componentValueObserved(vm: VM, key: PropertyKey) {
export function componentValueObserved(vm: VM, key: PropertyKey, target: any = {}) {
const { component, tro } = vm;
// On the server side, we don't need mutation tracking. Skipping it improves performance.
if (process.env.IS_BROWSER) {
valueObserved(vm.component, key);
valueObserved(component, key);
}

// The portion of reactivity that's exposed to signals is to subscribe a callback to re-render the VM (templates).
// We check check the following to ensure re-render is subscribed at the correct time.
// 1. The template is currently being rendered (there is a template reactive observer)
// 2. There was a call to a getter to access the signal (happens during vnode generation)
if (
lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS &&
isObject(target) &&
!isNull(target) &&
'value' in target &&
'subscribe' in target &&
isFunction(target.subscribe) &&
// Only subscribe if a template is being rendered by the engine
tro.isObserving()
) {
// Subscribe the template reactive observer's notify method, which will mark the vm as dirty and schedule hydration.
subscribeToSignal(component, target as Signal<any>, tro.notify.bind(tro));
}
}

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

export * from '../libs/mutation-tracker';
export * from '../libs/signal-tracker';
5 changes: 3 additions & 2 deletions packages/@lwc/engine-core/src/framework/observed-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export function createObservedFieldPropertyDescriptor(key: string): PropertyDesc
return {
get(this: LightningElement): any {
const vm = getAssociatedVM(this);
componentValueObserved(vm, key);
return vm.cmpFields[key];
const val = vm.cmpFields[key];
componentValueObserved(vm, key, val);
return val;
},
set(this: LightningElement, newValue: any) {
const vm = getAssociatedVM(this);
Expand Down
4 changes: 2 additions & 2 deletions packages/@lwc/engine-core/src/framework/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
markComponentAsDirty,
getTemplateReactiveObserver,
getComponentAPIVersion,
resetTemplateObserverAndUnsubscribe,
} from './component';
import {
addCallbackToNextTick,
Expand Down Expand Up @@ -272,9 +273,8 @@ function resetComponentStateWhenRemoved(vm: VM) {
const { state } = vm;

if (state !== VMState.disconnected) {
const { tro } = vm;
// Making sure that any observing record will not trigger the rehydrated on this vm
tro.reset();
resetTemplateObserverAndUnsubscribe(vm);
runDisconnectedCallback(vm);
// Spec: https://dom.spec.whatwg.org/#concept-node-remove (step 14-15)
runChildNodesDisconnectedCallback(vm);
Expand Down
4 changes: 4 additions & 0 deletions packages/@lwc/engine-core/src/libs/mutation-tracker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,8 @@ export class ReactiveObserver {
// we keep track of observing records where the observing record was added to so we can do some clean up later on
ArrayPush.call(this.listeners, reactiveObservers);
}

isObserving() {
return currentReactiveObserver === this;
}
}
92 changes: 92 additions & 0 deletions packages/@lwc/engine-core/src/libs/signal-tracker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2024, 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 { isFalse, isFunction, isUndefined } from '@lwc/shared';
import { Signal } from '@lwc/signals';
import { logWarnOnce } from '../../shared/logger';

/**
* This map keeps track of objects to signals. There is an assumption that the signal is strongly referenced
* on the object which allows the SignalTracker to be garbage collected along with the object.
*/
const TargetToSignalTrackerMap = new WeakMap<object, SignalTracker>();

function getSignalTracker(target: object) {
let signalTracker = TargetToSignalTrackerMap.get(target);
if (isUndefined(signalTracker)) {
signalTracker = new SignalTracker();
TargetToSignalTrackerMap.set(target, signalTracker);
}
return signalTracker;
}

export function subscribeToSignal(
target: Object,
signal: Signal<unknown>,
update: CallbackFunction
) {
const signalTracker = getSignalTracker(target);
if (isFalse(signalTracker.seen(signal))) {
signalTracker.subscribeToSignal(signal, update);
}
}

export function unsubscribeFromSignals(target: object) {
if (TargetToSignalTrackerMap.has(target)) {
const signalTracker = getSignalTracker(target);
signalTracker.unsubscribeFromSignals();
signalTracker.reset();
}
}

type CallbackFunction = () => void;

/**
* This class is used to keep track of the signals associated to a given object.
* It is used to prevent the LWC engine from subscribing duplicate callbacks multiple times
* to the same signal. Additionally, it keeps track of all signal unsubscribe callbacks, handles invoking
* them when necessary and discarding them.
*/
class SignalTracker {
private signalToUnsubscribeMap: Map<Signal<unknown>, CallbackFunction> = new Map();

seen(signal: Signal<unknown>) {
return this.signalToUnsubscribeMap.has(signal);
}

subscribeToSignal(signal: Signal<unknown>, update: CallbackFunction) {
try {
const unsubscribe = signal.subscribe(update);
if (isFunction(unsubscribe)) {
// TODO [#3978]: Evaluate how we should handle the case when unsubscribe is not a function.
// Long term we should throw an error or log a warning.
this.signalToUnsubscribeMap.set(signal, unsubscribe);
}
} catch (err: any) {
logWarnOnce(
`Attempted to subscribe to an object that has the shape of a signal but received the following error: ${
err?.stack ?? err
}`
);
}
}

unsubscribeFromSignals() {
try {
this.signalToUnsubscribeMap.forEach((unsubscribe) => unsubscribe());
} catch (err: any) {
logWarnOnce(
`Attempted to call a signal's unsubscribe callback but received the following error: ${
err?.stack ?? err
}`
);
}
}

reset() {
this.signalToUnsubscribeMap.clear();
}
}
1 change: 1 addition & 0 deletions packages/@lwc/features/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const features: FeatureFlagMap = {
ENABLE_FROZEN_TEMPLATE: null,
ENABLE_LEGACY_SCOPE_TOKENS: null,
ENABLE_FORCE_SHADOW_MIGRATE_MODE: null,
ENABLE_EXPERIMENTAL_SIGNALS: null,
DISABLE_TEMPORARY_V5_COMPILER_SUPPORT: null,
};

Expand Down
6 changes: 6 additions & 0 deletions packages/@lwc/features/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ export interface FeatureFlagMap {
*/
ENABLE_FORCE_SHADOW_MIGRATE_MODE: FeatureFlagValue;

/**
* EXPERIMENTAL FEATURE, DO NOT USE IN PRODUCTION
* If true, allows the engine to expose reactivity to signals as describe in @lwc/signals.
*/
ENABLE_EXPERIMENTAL_SIGNALS: FeatureFlagValue;

/**
* If true, disable temporary support for the LWC v5 compiler format.
*/
Expand Down
Loading

0 comments on commit 314ab66

Please sign in to comment.