Skip to content

Commit

Permalink
refactor: move logic for tracking signal subscription
Browse files Browse the repository at this point in the history
  • Loading branch information
jmsjtu committed Feb 2, 2024
1 parent 66f885b commit 65fa1cb
Show file tree
Hide file tree
Showing 15 changed files with 276 additions and 54 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.0.0"
},
"devDependencies": {
"observable-membrane": "2.0.0"
"observable-membrane": "2.0.0",
"@lwc/signals": "6.0.0"
}
}
9 changes: 8 additions & 1 deletion 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 @@ -92,6 +96,9 @@ export function renderComponent(vm: VM): VNodes {
}

vm.tro.reset();
if (lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS) {
unsubscribeFromSignals(vm.component);
}
const vnodes = invokeComponentRenderMethod(vm);
vm.isDirty = false;
vm.isScheduled = false;
Expand Down
1 change: 0 additions & 1 deletion packages/@lwc/engine-core/src/framework/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ 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
33 changes: 14 additions & 19 deletions packages/@lwc/engine-core/src/framework/mutation-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
valueMutated,
valueObserved,
} from '../libs/mutation-tracker';
import { subscribeToSignal } from '../libs/signal-tracker';
import { VM } from './vm';

const DUMMY_REACTIVE_OBSERVER = {
Expand All @@ -29,35 +30,28 @@ export function componentValueMutated(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);
}

// 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.
// 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 &&
target &&
typeof target === 'object' &&
'value' in target &&
'subscribe' in target &&
typeof target.subscribe === 'function'
typeof target.subscribe === 'function' &&
// Only subscribe if a template is being rendered by the engine
tro.isObserving()
) {
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
}
}
// Subscribe the template reactive observer's notify method, which will mark the vm as dirty and schedule hydration.
subscribeToSignal(component, target, tro.notify.bind(tro));
}
}

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

export * from '../libs/mutation-tracker';
export * from '../libs/signal-tracker';
7 changes: 5 additions & 2 deletions packages/@lwc/engine-core/src/framework/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
logGlobalOperationStart,
} from './profiler';
import { patchChildren } from './rendering';
import { ReactiveObserver } from './mutation-tracker';
import { ReactiveObserver, unsubscribeFromSignals } from './mutation-tracker';
import { connectWireAdapters, disconnectWireAdapters, installWireAdapters } from './wiring';
import {
VNodes,
Expand Down Expand Up @@ -272,9 +272,12 @@ function resetComponentStateWhenRemoved(vm: VM) {
const { state } = vm;

if (state !== VMState.disconnected) {
const { tro } = vm;
const { tro, component } = vm;
// Making sure that any observing record will not trigger the rehydrated on this vm
tro.reset();
if (lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS) {
unsubscribeFromSignals(component);
}
runDisconnectedCallback(vm);
// Spec: https://dom.spec.whatwg.org/#concept-node-remove (step 14-15)
runChildNodesDisconnectedCallback(vm);
Expand Down
33 changes: 10 additions & 23 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 | CallbackFunction)[] = [];
private listeners: ObservedMemberPropertyRecords[] = [];
private callback: CallbackFunction;

constructor(callback: CallbackFunction) {
Expand Down Expand Up @@ -101,23 +101,14 @@ export class ReactiveObserver {
if (len > 0) {
for (let i = 0; i < len; i++) {
const set = listeners[i];
// 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);
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 {
throw new Error(
`Unknown listener detected in mutation tracker, expected a set of function but received ${typeof set}`
);
// Slow case
const pos = ArrayIndexOf.call(set, this);
ArraySplice.call(set, pos, 1);
}
}
listeners.length = 0;
Expand All @@ -129,12 +120,8 @@ export class ReactiveObserver {
this.callback.call(undefined, 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);
}
link(reactiveObservers: ReactiveObserver[]) {
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);
}
Expand Down
84 changes: 84 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,84 @@
/*
* 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, 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: WeakMap<Object, SignalTracker> = new WeakMap();

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);
this.signalToUnsubscribeMap.set(signal, unsubscribe);
} catch (err) {
logWarnOnce(
`Attempted to subscribe to an object that has the shape of a signal but received the following error: ${err}`
);
}
}

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

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

// Engine-dom public APIs --------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/@lwc/features/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const features: FeatureFlagMap = {
ENABLE_FROZEN_TEMPLATE: null,
ENABLE_LEGACY_SCOPE_TOKENS: null,
ENABLE_FORCE_SHADOW_MIGRATE_MODE: null,
ENABLE_EXPERIMENTAL_SIGNALS: null,
};

// eslint-disable-next-line no-restricted-properties
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 @@ -69,6 +69,12 @@ export interface FeatureFlagMap {
* If true, enable experimental shadow DOM migration mode globally.
*/
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;
}

export type FeatureFlagName = keyof FeatureFlagMap;
75 changes: 75 additions & 0 deletions packages/@lwc/signals/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# @lwc/signals

This is an experimental package containing the interface expected for signals.

A key point to note is that when a signal is both bound to an LWC class member variable and used on a template,
the LWC engine will attempt to subscribe a callback to rerender the template.

## Reactivity with Signals

A Signal is an object that holds a value and allows components to react to changes to that value.
It exposes a `.value` property for accessing the current value, and `.subscribe` methods for responding to changes.

```js
import { signal } from 'some/signals';

export default class ExampleComponent extends LightningElement {
count = signal(0);

increment() {
this.count.value++;
}
}
```

In the template, we can bind directly to the `.value` property:

```html
<template>
<button onclick="{increment}">Increment</button>
<p>{count.value}</p>
</template>
```

## Supported APIs

This package supports the following APIs.

### Signal

This is the shape of the signal that the LWC engine expects.

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

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

### SignalBaseClass

A base class is provided as a starting point for implementation.

```js
export abstract class SignalBaseClass<T> implements Signal<T> {
abstract get value(): T;

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();
}
}
}
```
14 changes: 14 additions & 0 deletions packages/@lwc/signals/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2018, 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
*/
const BASE_CONFIG = require('../../../scripts/jest/base.config');

module.exports = {
...BASE_CONFIG,
displayName: 'lwc-signals',
roots: ['<rootDir>/src'],
testEnvironment: 'jsdom',
};
Loading

0 comments on commit 65fa1cb

Please sign in to comment.