216 lines
9.8 KiB
JavaScript
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
|