Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
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"
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be a dependency, or else it's gonna break things! (Alternatively, make the imports in mutation-tracker / signal-tracker conditional.)

Copy link
Contributor

Choose a reason for hiding this comment

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

@wjhsf The way devDependencies work in this repo is that they get inlined into the built dist files. So we use this as a kind of hack when we want certain built files not to contain any requires/imports.

In this case, I think this is the right approach since historically @lwc/engine-core doesn't have any imports. The question then is why not just make @lwc/signals part of @lwc/engine-core itself.

The answer to that, I think, is that @lwc/signals kind of acts as a documentation hub and an ergonomic way to import a base class that consumers can extend. The value of this does seem debatable to me (we could just export the same thing from @lwc/engine-dom/lwc), and historically we've annoyed users with a proliferation of @lwc/* repos they've had to install, and plus this is an experimental feature, so I would lean towards just moving it back to @lwc/engine-core for now.

}
}
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 &&
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
'subscribe' in target &&

No need since you're already checking it's a function below.

Copy link
Member Author

@jmsjtu jmsjtu Feb 6, 2024

Choose a reason for hiding this comment

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

@nolanlawson typescript is complaining when I do isFunction(target.subscribe), without checking for 'subscribe' in target so I added it back for now.

isFunction(target.subscribe) &&
// Only subscribe if a template is being rendered by the engine
tro.isObserving()
) {
Copy link
Contributor

Choose a reason for hiding this comment

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

These conditions look right to me for now. Sometime after this PR lands, we may want to replace the last three conditions with something like && target.isSignal === SUPER_SECRET_SYMBOL, where const SUPER_SECRET_SYMBOL === Symbol("SymbolSigil"). This would allow us to lock down initial usage of the signal machinery by restricting who has access to that symbol in the runtime (i.e. so that customers can't start using signals before we're ready).

// 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
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 {
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 this module to keep track of LWC component instances to signals bound the LWC (the signal is attached to the LWC class somewhere).

The reason this is needed is because during the rendering process, if a signal is referenced more than once on a template its getter is called multiple times.

Since we're hooking into the class' getter to determine whether a property access is a signal, there may be multiple attempts to subscribe the same callback to a single signal.

I thought about reusing the mutation-tracker library to track this information but decided against it to keep the abstraction clean.

The mutation-tracker library only really cares about the state within its own module and I felt introducing the concept of signals to it would pollute the abstraction.

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
}`
);
Copy link
Contributor

Choose a reason for hiding this comment

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

We should probably have a call to action here, i.e. tell the user what they need to do to resolve the error.

AIUI, this will occur if e.g. a plain object has a shape like { value: 'foo' }, right? Meaning we mistakenly sniff it as a signal?

If that's the case, then I don't know if we even want to warn here. value is a pretty common name, and I imagine users may be annoyed by even one warning message, if there's nothing they can do about it. What do other signal-using frameworks do in this case?

Copy link
Contributor

Choose a reason for hiding this comment

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

We're checking typeof obj.subscribe === 'function' elsewhere, so that is not the case that'd be hit here. Instead, this would occur if an error threw while inside the subscribe() call.

We definitely don't want errors in subscribe() to be swallowed entirely. However, I think coercing into a string here might not be the right thing. For example, you'd lose the call stack. On the other hand, a thrown object isn't guaranteed to be an actual error object. So maybe something like this:

logWarnOnce(`Attempted to subscribe to...: ${err?.stack ?? err}`);

Copy link
Member Author

Choose a reason for hiding this comment

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

@nolanlawson the signal shape should be { value: 'foo' , subscribe: () => {} }.

I'll have to do some more research on how other frameworks handle this situation but I know most of them have their own signal implementations, so they may not run into this issue.

I'll log the stack trace for now as @divmain suggested and look into how other frameworks handle this in more detail.

}
}

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
}`
);
Copy link
Contributor

Choose a reason for hiding this comment

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

This one seems a bit more serious to me. In this case, we might have landed here because a signal exposed a value and a subscribe, but not an unsubscribe. (I.e. you may have a signal implementation that is prone to memory leaks, since it doesn't implement unsubscribe.) I wonder if there is value in separating out the two cases and providing a better error message for each scenario.

Copy link
Contributor

Choose a reason for hiding this comment

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

Elsewhere, we are checking that typeof unsubscribe === 'function'. So we know at this point that unsubscribe is invokable. However, similar to the other condition, this could occur if an error occurs during unsubscribe, e.g. the signal wraps a websocket connection, unsubscribe() attempts to ws.close() and that fails for some reason.

Same ask as earlier for preserving the error's stack.

Copy link
Member Author

Choose a reason for hiding this comment

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

@nolanlawson In SignalTracker.subscribeToSignal we now check that the subscribe returns an unsubscribe function and only stores if it's a function.

For v1 I'll log the stack trace as @divmain suggested but I left a todo (#3978) to revisit this in 252 once we figure out the use cases and know how we want to better handle the errors.

}
}

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
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.
Copy link
Contributor

Choose a reason for hiding this comment

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

It just occurred to me: we do not technically need a new Karma env var, since ENABLE_EXPERIMENTAL_SIGNALS is a runtime-only flag. So you could just set the flag at runtime during the Karma tests.

Or do you intend to make it a compiler flag too in the future?

Copy link
Member Author

Choose a reason for hiding this comment

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

TIL! No, I don't intend for there to be a compiler flag in the future.

I've removed it from the karma tests.


## 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
Loading