rdesign/frontend/node_modules/@lit-labs/signals/development/lib/signal-watcher.js

216 lines
9.8 KiB
JavaScript

/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
import { Signal } from 'signal-polyfill';
const signalWatcherBrand = Symbol('SignalWatcherBrand');
// Memory management: We need to ensure that we don't leak memory by creating a
// reference cycle between an element and its watcher, which then it kept alive
// by the signals it watches. To avoid this, we break the cycle by using a
// WeakMap to store the watcher for each element, and a FinalizationRegistry to
// clean up the watcher when the element is garbage collected.
const elementFinalizationRegistry = new FinalizationRegistry(({ watcher, signal }) => {
watcher.unwatch(signal);
});
const elementForWatcher = new WeakMap();
/**
* Adds the ability for a LitElement or other ReactiveElement class to
* watch for access to signals during the update lifecycle and trigger a new
* update when signals values change.
*/
export function SignalWatcher(Base) {
// Only apply the mixin once
if (Base[signalWatcherBrand] === true) {
console.warn('SignalWatcher should not be applied to the same class more than once.');
return Base;
}
class SignalWatcher extends Base {
constructor() {
super(...arguments);
/**
* Used to force an uncached read of the __performUpdateSignal when we need
* to read the current value during an update.
*
* If https://github.com/tc39/proposal-signals/issues/151 is resolved, we
* won't need this.
*/
this.__forceUpdateSignal = new Signal.State(0);
/*
* This field is used within the watcher to determine if the watcher
* notification was triggered by our performUpdate() override. Because we
* force a fresh read of the __performUpdateSignal by changing value of the
* __forceUpdate signal, the watcher will be notified. But we're already
* performing an update, so we don't want to enqueue another one.
*/
// @ts-expect-error This field is accessed in a watcher function with a
// different `this` context, so TypeScript can't see the access.
this.__forcingUpdate = false;
/**
* Whether or not the next update should perform a full render, or if only
* pending watches should be committed.
*
* If requestUpdate() was called only because of watch() directive updates,
* then we can just commit those directives without a full render. If
* requestUpdate() was called for any other reason, we need to perform a
* full render, and don't need to separately commit the watch() directives.
*
* This is set to `true` initially, and whenever requestUpdate() is called
* outside of a watch() directive update. It is set to `false` when
* update() is called, so that a requestUpdate() is required to do another
* full render.
*/
this.__doFullRender = true;
/**
* Set of watch directives that have been updated since the last update.
* These will be committed in update() to ensure that the latest value is
* rendered and that all updates are batched.
*/
this.__pendingWatches = new Set();
}
__watch() {
if (this.__watcher !== undefined) {
return;
}
// We create a fresh computed instead of just re-using the existing one
// because of https://github.com/proposal-signals/signal-polyfill/issues/27
this.__performUpdateSignal = new Signal.Computed(() => {
this.__forceUpdateSignal.get();
super.performUpdate();
});
const watcher = (this.__watcher = new Signal.subtle.Watcher(function () {
// All top-level references in this function body must either be `this`
// (the watcher) or a module global to prevent this closure from keeping
// the enclosing scopes alive, which would keep the element alive. So
// The only two references are `this` and `elementForWatcher`.
const el = elementForWatcher.get(this);
if (el === undefined) {
// The element was garbage collected, so we can stop watching.
return;
}
if (el.__forcingUpdate === false) {
el.requestUpdate();
}
this.watch();
}));
elementForWatcher.set(watcher, this);
elementFinalizationRegistry.register(this, {
watcher,
signal: this.__performUpdateSignal,
});
watcher.watch(this.__performUpdateSignal);
}
__unwatch() {
if (this.__watcher === undefined) {
return;
}
this.__watcher.unwatch(this.__performUpdateSignal);
this.__performUpdateSignal = undefined;
this.__watcher = undefined;
}
performUpdate() {
if (!this.isUpdatePending) {
// super.performUpdate() performs this check, so we bail early so that
// we don't read the __performUpdateSignal when it's not going to access
// any signals. This keeps the last signals read as the sources so that
// we'll get notified of changes to them.
return;
}
// Always enable watching before an update, even if disconnected, so that
// we can track signals that are accessed during the update.
this.__watch();
// Force an uncached read of __performUpdateSignal
this.__forcingUpdate = true;
this.__forceUpdateSignal.set(this.__forceUpdateSignal.get() + 1);
this.__forcingUpdate = false;
// Always read from the signal to ensure that it's tracked
this.__performUpdateSignal.get();
}
update(changedProperties) {
// We need a try block because both super.update() and
// WatchDirective.commit() can throw, and we need to ensure that post-
// update cleanup happens.
try {
if (this.__doFullRender) {
// Force future updates to not perform full renders by default.
this.__doFullRender = false;
super.update(changedProperties);
}
else {
// For a partial render, just commit the pending watches.
// TODO (justinfagnani): Should we access each signal in a separate
// try block?
this.__pendingWatches.forEach((d) => d.commit());
}
}
finally {
// If we didn't call super.update(), we need to set this to false
this.isUpdatePending = false;
this.__pendingWatches.clear();
}
}
requestUpdate(name, oldValue, options) {
this.__doFullRender = true;
super.requestUpdate(name, oldValue, options);
}
connectedCallback() {
super.connectedCallback();
// Because we might have missed some signal updates while disconnected,
// we force a full render on the next update.
this.requestUpdate();
}
disconnectedCallback() {
super.disconnectedCallback();
// Clean up the watcher earlier than the FinalizationRegistry will, to
// avoid memory pressure from signals holding references to the element
// via the watcher.
//
// This means that while disconnected, regular reactive property updates
// will trigger a re-render, but signal updates will not. To ensure that
// current signal usage is still correctly tracked, we re-enable watching
// in performUpdate() even while disconnected. From that point on, a
// disconnected element will be retained by the signals it accesses during
// the update lifecycle.
//
// We use queueMicrotask() to ensure that this cleanup does not happen
// because of moves in the DOM within the same task, such as removing an
// element with .remove() and then adding it back later with .append()
// in the same task. For example, repeat() works this way.
queueMicrotask(() => {
if (this.isConnected === false) {
this.__unwatch();
}
});
}
/**
* Enqueues an update caused by a signal change observed by a watch()
* directive.
*
* Note: the method is not part of the public API and is subject to change.
* In particular, it may be removed if the watch() directive is updated to
* work with standalone lit-html templates.
*
* @internal
*/
_updateWatchDirective(d) {
this.__pendingWatches.add(d);
// requestUpdate() will set __doFullRender to true, so remember the
// current value and restore it after calling requestUpdate().
const shouldRender = this.__doFullRender;
this.requestUpdate();
this.__doFullRender = shouldRender;
}
/**
* Clears a watch() directive from the set of pending watches.
*
* Note: the method is not part of the public API and is subject to change.
*
* @internal
*/
_clearWatchDirective(d) {
this.__pendingWatches.delete(d);
}
}
return SignalWatcher;
}
//# sourceMappingURL=signal-watcher.js.map