chrome-devtools-frontend
Version:
Chrome DevTools UI
414 lines (399 loc) • 16 kB
text/typescript
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
/**
* Overview:
*
* This module is designed to add support for an async `setValue` API and
* `disconnected` callback to directives with the least impact on the core
* runtime or payload when that feature is not used.
*
* The strategy is to introduce a `AsyncDirective` subclass of
* `Directive` that climbs the "parent" tree in its constructor to note which
* branches of lit-html's "logical tree" of data structures contain such
* directives and thus need to be crawled when a subtree is being cleared (or
* manually disconnected) in order to run the `disconnected` callback.
*
* The "nodes" of the logical tree include Parts, TemplateInstances (for when a
* TemplateResult is committed to a value of a ChildPart), and Directives; these
* all implement a common interface called `DisconnectableChild`. Each has a
* `_$parent` reference which is set during construction in the core code, and a
* `_$disconnectableChildren` field which is initially undefined.
*
* The sparse tree created by means of the `AsyncDirective` constructor
* crawling up the `_$parent` tree and placing a `_$disconnectableChildren` Set
* on each parent that includes each child that contains a
* `AsyncDirective` directly or transitively via its children. In order
* disconnect (or reconnect) a tree, the `_$setChildPartConnected` API is patched
* onto ChildParts as a directive climbs the parent tree, which is called by the
* core when clearing a part if it exists. When called, that method iterates
* over the sparse tree of Set<DisconnectableChildren> built up by
* AsyncDirectives, and calls `_$setDirectiveConnected` on any
* directives that are encountered in that tree, running the required callbacks.
*
* A given "logical tree" of lit-html data-structures might look like this:
*
* ChildPart(N1) _$dC=[D2,T3]
* ._directive
* AsyncDirective(D2)
* ._value // user value was TemplateResult
* TemplateInstance(T3) _$dC=[A4,A6,N10,N12]
* ._parts[]
* AttributePart(A4) _$dC=[D5]
* ._directives[]
* AsyncDirective(D5)
* AttributePart(A6) _$dC=[D7,D8]
* ._directives[]
* AsyncDirective(D7)
* Directive(D8) _$dC=[D9]
* ._directive
* AsyncDirective(D9)
* ChildPart(N10) _$dC=[D11]
* ._directive
* AsyncDirective(D11)
* ._value
* string
* ChildPart(N12) _$dC=[D13,N14,N16]
* ._directive
* AsyncDirective(D13)
* ._value // user value was iterable
* Array<ChildPart>
* ChildPart(N14) _$dC=[D15]
* ._value
* string
* ChildPart(N16) _$dC=[D17,T18]
* ._directive
* AsyncDirective(D17)
* ._value // user value was TemplateResult
* TemplateInstance(T18) _$dC=[A19,A21,N25]
* ._parts[]
* AttributePart(A19) _$dC=[D20]
* ._directives[]
* AsyncDirective(D20)
* AttributePart(A21) _$dC=[22,23]
* ._directives[]
* AsyncDirective(D22)
* Directive(D23) _$dC=[D24]
* ._directive
* AsyncDirective(D24)
* ChildPart(N25) _$dC=[D26]
* ._directive
* AsyncDirective(D26)
* ._value
* string
*
* Example 1: The directive in ChildPart(N12) updates and returns `nothing`. The
* ChildPart will _clear() itself, and so we need to disconnect the "value" of
* the ChildPart (but not its directive). In this case, when `_clear()` calls
* `_$setChildPartConnected()`, we don't iterate all of the
* _$disconnectableChildren, rather we do a value-specific disconnection: i.e.
* since the _value was an Array<ChildPart> (because an iterable had been
* committed), we iterate the array of ChildParts (N14, N16) and run
* `setConnected` on them (which does recurse down the full tree of
* `_$disconnectableChildren` below it, and also removes N14 and N16 from N12's
* `_$disconnectableChildren`). Once the values have been disconnected, we then
* check whether the ChildPart(N12)'s list of `_$disconnectableChildren` is empty
* (and would remove it from its parent TemplateInstance(T3) if so), but since
* it would still contain its directive D13, it stays in the disconnectable
* tree.
*
* Example 2: In the course of Example 1, `setConnected` will reach
* ChildPart(N16); in this case the entire part is being disconnected, so we
* simply iterate all of N16's `_$disconnectableChildren` (D17,T18) and
* recursively run `setConnected` on them. Note that we only remove children
* from `_$disconnectableChildren` for the top-level values being disconnected
* on a clear; doing this bookkeeping lower in the tree is wasteful since it's
* all being thrown away.
*
* Example 3: If the LitElement containing the entire tree above becomes
* disconnected, it will run `childPart.setConnected()` (which calls
* `childPart._$setChildPartConnected()` if it exists); in this case, we
* recursively run `setConnected()` over the entire tree, without removing any
* children from `_$disconnectableChildren`, since this tree is required to
* re-connect the tree, which does the same operation, simply passing
* `isConnectd: true` down the tree, signaling which callback to run.
*/
import {isSingleExpression} from './directive-helpers.js';
import {Directive, PartInfo, PartType} from './directive.js';
import {
AttributePart,
ChildPart,
Disconnectable,
noChange,
Part,
} from './lit-html.js';
export {directive} from './directive.js';
const DEV_MODE = true;
/**
* Recursively walks down the tree of Parts/TemplateInstances/Directives to set
* the connected state of directives and run `disconnected`/ `reconnected`
* callbacks.
*
* @return True if there were children to disconnect; false otherwise
*/
const setChildrenConnected = (
parent: Disconnectable,
isConnected: boolean
): boolean => {
const children = parent._$disconnetableChildren;
if (children === undefined) {
return false;
}
for (const obj of children) {
// The existence of `_$setDirectiveConnected` is used as a "brand" to
// disambiguate AsyncDirectives from other DisconnectableChildren
// (as opposed to using an instanceof check to know when to call it); the
// redundancy of "Directive" in the API name is to avoid conflicting with
// `_$setChildPartConnected`, which exists `ChildParts` which are also in
// this list
// Disconnect Directive (and any nested directives contained within)
(obj as AsyncDirective)._$setDirectiveConnected?.(isConnected, false);
// Disconnect Part/TemplateInstance
setChildrenConnected(obj, isConnected);
}
return true;
};
/**
* Removes the given child from its parent list of disconnectable children, and
* if the parent list becomes empty as a result, removes the parent from its
* parent, and so forth up the tree when that causes subsequent parent lists to
* become empty.
*/
const removeDisconnectableFromParent = (obj: Disconnectable) => {
let parent, children;
do {
if ((parent = obj._$parent) === undefined) {
break;
}
children = parent._$disconnetableChildren!;
children.delete(obj);
obj = parent;
} while (children?.size === 0);
};
const addDisconnectableToParent = (obj: Disconnectable) => {
// Climb the parent tree, creating a sparse tree of children needing
// disconnection
for (let parent; (parent = obj._$parent); obj = parent) {
let children = parent._$disconnetableChildren;
if (children === undefined) {
parent._$disconnetableChildren = children = new Set();
} else if (children.has(obj)) {
// Once we've reached a parent that already contains this child, we
// can short-circuit
break;
}
children.add(obj);
installDisconnectAPI(parent);
}
};
/**
* Changes the parent reference of the ChildPart, and updates the sparse tree of
* Disconnectable children accordingly.
*
* Note, this method will be patched onto ChildPart instances and called from
* the core code when parts are moved between different parents.
*/
function reparentDisconnectables(this: ChildPart, newParent: Disconnectable) {
if (this._$disconnetableChildren !== undefined) {
removeDisconnectableFromParent(this);
this._$parent = newParent;
addDisconnectableToParent(this);
} else {
this._$parent = newParent;
}
}
/**
* Sets the connected state on any directives contained within the committed
* value of this part (i.e. within a TemplateInstance or iterable of
* ChildParts) and runs their `disconnected`/`reconnected`s, as well as within
* any directives stored on the ChildPart (when `valueOnly` is false).
*
* `isClearingValue` should be passed as `true` on a top-level part that is
* clearing itself, and not as a result of recursively disconnecting directives
* as part of a `clear` operation higher up the tree. This both ensures that any
* directive on this ChildPart that produced a value that caused the clear
* operation is not disconnected, and also serves as a performance optimization
* to avoid needless bookkeeping when a subtree is going away; when clearing a
* subtree, only the top-most part need to remove itself from the parent.
*
* `fromPartIndex` is passed only in the case of a partial `_clear` running as a
* result of truncating an iterable.
*
* Note, this method will be patched onto ChildPart instances and called from the
* core code when parts are cleared or the connection state is changed by the
* user.
*/
function setChildPartConnected(
this: ChildPart,
isConnected: boolean,
isClearingValue = false,
fromPartIndex = 0
) {
const value = this._$committedValue;
const children = this._$disconnetableChildren;
if (children === undefined || children.size === 0) {
return;
}
if (isClearingValue) {
if (Array.isArray(value)) {
// Iterable case: Any ChildParts created by the iterable should be
// disconnected and removed from this ChildPart's disconnectable
// children (starting at `fromPartIndex` in the case of truncation)
for (let i = fromPartIndex; i < value.length; i++) {
setChildrenConnected(value[i], false);
removeDisconnectableFromParent(value[i]);
}
} else if (value != null) {
// TemplateInstance case: If the value has disconnectable children (will
// only be in the case that it is a TemplateInstance), we disconnect it
// and remove it from this ChildPart's disconnectable children
setChildrenConnected(value as Disconnectable, false);
removeDisconnectableFromParent(value as Disconnectable);
}
} else {
setChildrenConnected(this, isConnected);
}
}
/**
* Patches disconnection API onto ChildParts.
*/
const installDisconnectAPI = (obj: Disconnectable) => {
if ((obj as ChildPart).type == PartType.CHILD) {
(obj as ChildPart)._$setChildPartConnected ??= setChildPartConnected;
(obj as ChildPart)._$reparentDisconnectables ??= reparentDisconnectables;
}
};
/**
* An abstract `Directive` base class whose `disconnected` method will be
* called when the part containing the directive is cleared as a result of
* re-rendering, or when the user calls `part.setDirectiveConnection(false)` on
* a part that was previously rendered containing the directive.
*
* If `part.setDirectiveConnection(true)` is subsequently called on a
* containing part, the directive's `reconnected` method will be called prior
* to its next `update`/`render` callbacks. When implementing `disconnected`,
* `reconnected` should also be implemented to be compatible with reconnection.
*/
export abstract class AsyncDirective extends Directive {
isConnected = true;
private _pendingValue: unknown = noChange;
// @internal
_$disconnetableChildren?: Set<Disconnectable> = undefined;
/**
* Initialize the part with internal fields
* @param part
* @param parent
* @param attributeIndex
*/
_$initialize(
part: Part,
parent: Disconnectable,
attributeIndex: number | undefined
) {
super._$initialize(part, parent, attributeIndex);
addDisconnectableToParent(this);
}
/**
* Called from the core code when a directive is going away from a part (in
* which case `shouldRemoveFromParent` should be true), and from the
* `setChildrenConnected` helper function when recursively changing the
* connection state of a tree (in which case `shouldRemoveFromParent` should
* be false).
*
* @param isConnected
* @param isClearingDirective - True when the directive itself is being
* removed; false when the tree is being disconnected
* @internal
*/
_$setDirectiveConnected(isConnected: boolean, isClearingDirective = true) {
this._setConnected(isConnected);
if (isClearingDirective) {
setChildrenConnected(this, isConnected);
removeDisconnectableFromParent(this);
}
}
/**
* Private method used to set the connection state of the directive and call
* the respective `disconnected` or `reconnected` callback. Note thatsince
* `isConnected` defaults to true, we do not run `reconnected` on first
* render.
*
* If a call to `setValue` was made while disconnected, flush it to the part
* before reconnecting.
*
* @param isConnected
* @internal
*/
private _setConnected(isConnected: boolean) {
if (isConnected !== this.isConnected) {
if (isConnected) {
this.isConnected = true;
if (this._pendingValue !== noChange) {
this.setValue(this._pendingValue);
this._pendingValue = noChange;
}
this.reconnected?.();
} else {
this.isConnected = false;
this.disconnected?.();
}
}
}
/**
* Override of the base `_resolve` method to ensure `reconnected` is run
* prior to the next render.
*
* @override
* @internal
*/
_$resolve(part: Part, props: Array<unknown>): unknown {
if (!this.isConnected) {
throw new Error(
`AsyncDirective ${this.constructor.name} was ` +
`rendered while its tree was disconnected.`
);
}
return super._$resolve(part, props);
}
/**
* Sets the value of the directive's Part outside the normal `update`/`render`
* lifecycle of a directive.
*
* This method should not be called synchronously from a directive's `update`
* or `render`.
*
* If the method is called while the part is disconnected, the value will be
* queued until directive is reconnected.
*
* @param directive The directive to update
* @param value The value to set
*/
setValue(value: unknown) {
if (this.isConnected) {
if (isSingleExpression((this.__part as unknown) as PartInfo)) {
this.__part._$setValue(value, this);
} else {
// this.__attributeIndex will be defined in this case, but
// assert it in dev mode
if (DEV_MODE && this.__attributeIndex === undefined) {
throw new Error(`Expected this.__attributeIndex to be a number`);
}
const newValues = [...(this.__part._$committedValue as Array<unknown>)];
newValues[this.__attributeIndex!] = value;
(this.__part as AttributePart)._$setValue(newValues, this, 0);
}
} else {
this._pendingValue = value;
}
}
/**
* User callbacks for implementing logic to release any resources/subscriptions
* that may have been retained by this directive. Since directives may also be
* re-connected, `reconnected` should also be implemented to restore the
* working state of the directive prior to the next render.
*/
protected disconnected() {}
protected reconnected() {}
}