Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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
1 change: 1 addition & 0 deletions packages/@lwc/engine-core/src/framework/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export { freezeTemplate } from './freeze-template';

// Experimental or Internal APIs
export { getComponentConstructor } from './get-component-constructor';
export { Signal, SignalBaseClass } from '../libs/signal';

// Types -------------------------------------------------------------------------------------------
export type { RendererAPI, LifecycleCallback } from './renderer';
Expand Down
28 changes: 27 additions & 1 deletion packages/@lwc/engine-core/src/framework/mutation-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,37 @@ export function componentValueMutated(vm: VM, key: PropertyKey) {
}
}

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

// Putting this here for now, the idea is to subscribe to a signal when there is an active template reactive observer.
// This would indicate that:
// 1. The template is currently being rendered
// 2. There was a call to a getter bound to the LWC class
// With this information we can infer that it is safe to subscribe the re-render callback to the signal, which will
// mark the VM as dirty and schedule rehydration.
if (
target &&
typeof target === 'object' &&
'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.

typeof target.subscribe === 'function'
) {
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).

if (vm.tro.isObserving()) {
try {
// In a future optimization, rather than re-render the entire VM we could use fine grained reactivity here
// to only re-render the part of the DOM that has been changed by the signal.
// jtu-todo: this will subscribe multiple functions since the callback is always different, look for a way to deduplicate this
const unsubscribe = target.subscribe(() => vm.tro.notify());
vm.tro.link(unsubscribe);
} catch (e) {
// throw away for now
}
}
}
}

export function createReactiveObserver(callback: CallbackFunction): ReactiveObserver {
Expand Down
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
37 changes: 27 additions & 10 deletions packages/@lwc/engine-core/src/libs/mutation-tracker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export type CallbackFunction = (rp: ReactiveObserver) => void;
export type JobFunction = () => void;

export class ReactiveObserver {
private listeners: ObservedMemberPropertyRecords[] = [];
private listeners: (ObservedMemberPropertyRecords | CallbackFunction)[] = [];
private callback: CallbackFunction;

constructor(callback: CallbackFunction) {
Expand Down Expand Up @@ -101,14 +101,23 @@ export class ReactiveObserver {
if (len > 0) {
for (let i = 0; i < len; i++) {
const set = listeners[i];
if (set.length === 1) {
// Perf optimization for the common case - the length is usually 1, so avoid the indexOf+splice.
// If the length is 1, we can also be sure that `this` is the first item in the array.
set.length = 0;
// jtu-todo: use the .call annotation here instead
if (Array.isArray(set)) {
if (set.length === 1) {
// Perf optimization for the common case - the length is usually 1, so avoid the indexOf+splice.
// If the length is 1, we can also be sure that `this` is the first item in the array.
set.length = 0;
} else {
// Slow case
const pos = ArrayIndexOf.call(set, this);
ArraySplice.call(set, pos, 1);
}
} else if (typeof set === 'function') {
set.call(undefined, this);
} else {
// Slow case
const pos = ArrayIndexOf.call(set, this);
ArraySplice.call(set, pos, 1);
throw new Error(
`Unknown listener detected in mutation tracker, expected a set of function but received ${typeof set}`
);
}
}
listeners.length = 0;
Expand All @@ -120,9 +129,17 @@ export class ReactiveObserver {
this.callback.call(undefined, this);
}

link(reactiveObservers: ReactiveObserver[]) {
ArrayPush.call(reactiveObservers, this);
// jtu-todo: add some comments here about why CallbackFunction is an acceptable type to link (eg. it's for signals)
// technically the CallbackFunction takes a ReactiveObserver argument but we're not passing one in with the subscribe
link(reactiveObservers: ReactiveObserver[] | CallbackFunction) {
if (Array.isArray(reactiveObservers)) {
ArrayPush.call(reactiveObservers, this);
}
// 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;
}
}
33 changes: 33 additions & 0 deletions packages/@lwc/engine-core/src/libs/signal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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
*/

export type OnUpdate = () => void;
export type Unsubscribe = () => void;

export interface Signal<ValueShape> {
get value(): ValueShape;
subscribe(onUpdate: OnUpdate): Unsubscribe;
}

export abstract class SignalBaseClass<ValueShape> implements Signal<ValueShape> {
abstract get value(): ValueShape;

private subscribers: Set<OnUpdate> = new Set();

subscribe(onUpdate: OnUpdate) {
this.subscribers.add(onUpdate);
return () => {
this.subscribers.delete(onUpdate);
};
}

protected notify() {
for (const subscriber of this.subscribers) {
subscriber();
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

As we discussed, I'd love for this to be moved to a new top-level @lwc/signals package that exposes the types and base class. I would have recommended moving to @lwc/shared, except that is intended as an internal package with other @lwc/* packages being the only direct consumers. In contrast, the signals type and base call may be re-used across the SF ecosystem.

Copy link
Member Author

Choose a reason for hiding this comment

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

@divmain I moved everything to a new package and added a readme.

If you get a chance, can you look through the readme too?

2 changes: 2 additions & 0 deletions packages/@lwc/engine-dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export {
getComponentConstructor,
__unstable__ProfilerControl,
__unstable__ReportingControl,
Signal,
SignalBaseClass,
} from '@lwc/engine-core';

// Engine-dom public APIs --------------------------------------------------------------------------
Expand Down