lume
Version:
610 lines (495 loc) • 23.5 kB
text/typescript
// TODO Remove isScene and isNode specifics out of here here
// TODO Some logic in SharedAPI actually belongs in here, and relies on
// childConnectedCallback. Untangle that from SharedAPI so CompositionTracker
// can fully contain the composition tracking.
// TODO After the above, move this class along with ChildTracker to
// `@lume/element` or somewhere as generic custom element utilities. Sub-classes
// should filter out specific undesired elements while CompositionTracker is generic.
// TODO a more generic v2 implementation: a node shuold be able to observe when
// it is composed into any element, no matter if the element is custom or not.
// Currently, we rely on the composed parent and children both extending from
// CompositionTracker for composition tracking to work, but if an element gets
// composed into some other element like a regular `<div>`, composition is not
// tracked.
// What we need to approximately do is have a CompositionTracker instance detect
// its regular parentElement in `connectedCallback` no matter what element it
// is, observe if it has a `ShadowRoot` by patching global `attachShadow` (with
// the limitation that the code has to be imported before any roots are
// attached) so that we can react to the presence of a ShadowRoot now or in the
// future, then we should enact similar logic as in this class in the
// arbitrary parent element's ShadowRoot.
import {Constructor} from 'lowclass/dist/Constructor.js'
import {observeChildren} from './utils/observeChildren.js'
import type {PossibleCustomElement, PossibleCustomElementConstructor} from './PossibleCustomElement.js'
import {isDomEnvironment, isScene} from './utils/isThisOrThat.js'
export const triggerChildComposedCallback = Symbol('triggerChildComposedCallback')
export const triggerChildUncomposedCallback = Symbol('triggerChildUncomposedCallback')
export function CompositionTracker<T extends Constructor<HTMLElement>>(Base: T) {
return class CompositionTracker extends Constructor<PossibleCustomElement, PossibleCustomElementConstructor & T>(
Base,
) {
// from Scene
isScene = false
// from Element3D
isElement3D = false
// A subclass can set this to false to skip observation of its ShadowRoot.
skipShadowObservation = false
// COMPOSED TREE TRACKING:
// Overriding HTMLElement.prototype.attachShadow here is part of our
// implementation for tracking the composed tree and connecting THREE
// objects in the same structure as the DOM composed tree so that it will
// render as expected when end users compose elements with ShadowDOM and
// slots.
override attachShadow(options: ShadowRootInit): ShadowRoot {
const root = super.attachShadow(options)
if (this.skipShadowObservation) return root
this.exposedShadowRoot = root
observeChildren({
target: root,
onConnect: this.#shadowRootChildAdded.bind(this),
onDisconnect: this.#shadowRootChildRemoved.bind(this),
})
// Arrray.from is needed for older Safari which can't iterate on HTMLCollection
const children = Array.from(this.children)
for (const child of children) {
if (!(child instanceof CompositionTracker)) continue
child.isPossiblySlotted = true
this.#this[triggerChildUncomposedCallback](child, 'actual')
}
return root
}
// COMPOSED TREE TRACKING:
get _hasShadowRoot(): boolean {
return !!this.exposedShadowRoot
}
// COMPOSED TREE TRACKING:
get _isPossiblyDistributedToShadowRoot(): boolean {
return this.isPossiblySlotted
}
// COMPOSED TREE TRACKING:
get _shadowRootParent(): CompositionTracker | null {
return this.shadowParent
}
get _shadowRootChildren(): CompositionTracker[] {
if (!this.exposedShadowRoot) return []
return Array.from(this.exposedShadowRoot.children).filter(
(n): n is CompositionTracker => n instanceof CompositionTracker,
)
}
// COMPOSED TREE TRACKING: Elements that are slotted to a slot that is
// child of a ShadowRoot of this element.
get _distributedShadowRootChildren(): CompositionTracker[] {
const result: CompositionTracker[] = []
for (const child of Array.from(this.exposedShadowRoot?.children || [])) {
if (child instanceof HTMLSlotElement && !child.assignedSlot) {
for (const slotted of child.assignedElements({flatten: true})) {
if (slotted instanceof CompositionTracker) result.push(slotted)
}
}
}
return result
}
// COMPOSED TREE TRACKING:
get _distributedParent(): CompositionTracker | null {
return this.slottedParent
}
// COMPOSED TREE TRACKING:
get _distributedChildren(): CompositionTracker[] | null {
return this.slottedChildren ? [...this.slottedChildren] : null
}
__composedParent: Element | null = null
get composedParent(): Element | null {
let result = this.__composedParent
if (!result) {
result = this.__getComposedParent()
}
return result
}
// Returns composed state calculated only during composition, which can
// be incorrect in the edge case described in
// __getSlottedChildDifference (faster).
get __isComposed() {
return this.__composedParent
}
// Returns the correct composed state even if our tracking is incorrect,
// by inspecting the DOM (slower).
get isComposed() {
return this.composedParent
}
// COMPOSED TREE TRACKING: The composed parent is the parent that this element renders relative
// to in the flat tree (composed tree).
__getComposedParent(): HTMLElement | null {
let parent: Node | null = this.parentElement
// Special case only for Nodes that are children of a Scene.
// TODO filtering should be done by subclasses
if (parent && isScene(parent)) return parent
parent = this.slottedParent || this.shadowParent
// Shortcut in case we have already detected slotted or shadowRoot parent.
if (parent) return parent as HTMLElement
return getComposedParent(this)
}
// COMPOSED TREE TRACKING: Composed children are the children that render relative to this
// element in the flat tree (composed tree), whether as children of a
// shadow root, or slotted children (assigned nodes) of a <slot>
// element.
get _composedChildren(): CompositionTracker[] {
if (this.exposedShadowRoot) {
return [...this._distributedShadowRootChildren, ...this._shadowRootChildren]
} else {
return [
...(this.slottedChildren || []), // TODO perhaps use slot.assignedElements instead?
// We only care about other nodes of the same type.
...Array.from(this.children).filter((n): n is CompositionTracker => n instanceof CompositionTracker),
]
}
}
// COMPOSED TREE TRACKING:
/** This element's ShadowRoot, if any (even if it is a closed shadow root, unlike the `shadowRoot` property) */
exposedShadowRoot?: ShadowRoot
// COMPOSED TREE TRACKING:
/**
* When true, it means this element's parent has a ShadowRoot, which
* means this element is possibly slotted into that parent's ShadowRoot.
* This doesn't mean that this element is slotted, it may not be slotted
* if there's no matching `<slot>` element to be slotted to.
*
* This is similar to `Boolean(this.parentElement.shadowRoot)`, except
* isPossiblySlotted is accurate even if the ShadowRoot mode is closed.
*/
isPossiblySlotted = false
#prevAssignedNodes?: WeakMap<HTMLSlotElement, Element[]>
// COMPOSED TREE TRACKING:
// A map of the slot elements that are children of this element and
// their last-known assigned elements. When a slotchange happens while
// this element is in a shadow root and has a slot child, we can
// detect what the difference is between the last known assigned elements and the new
// ones.
get __previousSlotAssignedNodes() {
if (!this.#prevAssignedNodes) this.#prevAssignedNodes = new WeakMap()
return this.#prevAssignedNodes
}
// COMPOSED TREE TRACKING:
/**
* If this element is slotted into a shadow tree, this will reference
* the parent element of the <slot> element where this element is
* slotted to. This element will render as a child of that parent
* element in the flat tree (composed tree).
*
* This is similar to `this.assignedSlot.parentElement`, except
* `slottedParent` returns a result even if the ShadowRoot mode is
* closed.
*/
slottedParent: CompositionTracker | null = null
// COMPOSED TREE TRACKING:
/**
* If this element is a top-level child of a ShadowRoot, then this points
* to the ShadowRoot host. The ShadowRoot host is the prent element that
* this element renders relative to in the composed tree.
*
* This is similar to `this.parentNode.host ?? null`.
*/
shadowParent: CompositionTracker | null = null
// COMPOSED TREE TRACKING:
/**
* If this element has a child `<slot>` element while in a ShadowRoot,
* then this will be a Set of the nodes slotted into the `<slot>`, and
* those nodes render relative to this element in the composed tree.
* This is `null` if there are no slotted children.
*/
slottedChildren: Set<CompositionTracker> | null = null
#this = this as any
// COMPOSED TREE TRACKING: Called when a child is added to the ShadowRoot of this element.
// This does not run for Scene instances, which already have a root for their rendering implementation.
#shadowRootChildAdded(child: Element) {
// NOTE Logic here is similar to childConnectedCallback
if (child instanceof CompositionTracker) {
child.shadowParent = this
this.#this[triggerChildComposedCallback](child, 'root')
} else if (child instanceof HTMLSlotElement) {
child.addEventListener('slotchange', this.__onChildSlotChange)
this.__handleSlottedChildren(child)
}
}
// COMPOSED TREE TRACKING: Called when a child is removed from the ShadowRoot of this element.
// This does not run for Scene instances, which already have a root for their rendering implementation.
#shadowRootChildRemoved(child: Element) {
// NOTE Logic here is similar to childDisconnectedCallback
if (child instanceof CompositionTracker) {
child.shadowParent = null
this.#this[triggerChildUncomposedCallback](child, 'root')
} else if (child instanceof HTMLSlotElement) {
child.removeEventListener('slotchange', this.__onChildSlotChange, {capture: true})
this.__handleSlottedChildren(child)
this.__previousSlotAssignedNodes.delete(child)
}
}
// COMPOSED TREE TRACKING: Called when a slot child of this element emits a slotchange event.
// TODO we need an @lazy decorator instead of making this a getter manually.
get __onChildSlotChange(): (event: Event) => void {
if (this.__onChildSlotChange__) return this.__onChildSlotChange__
this.__onChildSlotChange__ = (event: Event) => {
// event.currentTarget is the slot that this event handler is on,
// while event.target is always the slot from the ancestor-most
// tree if that slot is assigned to this slot or another slot that
// ultimate distributes to this slot.
const slot = event.currentTarget as HTMLSlotElement
this.__handleSlottedChildren(slot)
}
return this.__onChildSlotChange__
}
__onChildSlotChange__?: (event: Event) => void
// COMPOSED TREE TRACKING: Life cycle methods for use by subclasses to run
// logic when children are composed or uncomposed to them in the composed
// tree.
childComposedCallback?(composedChild: Element, compositionType: CompositionType): void
childUncomposedCallback?(uncomposedChild: Element, compositionType: CompositionType): void
composedCallback?(composedParent: Element, compositionType: CompositionType): void
uncomposedCallback?(uncomposedParent: Element, compositionType: CompositionType): void
#discrepancy = false;
[triggerChildComposedCallback as any](child: CompositionTracker, compositionType: CompositionType) {
if (child.#discrepancy) return
child.__composedParent = this
const trigger = () => {
this.childComposedCallback?.(child, compositionType)
child.composedCallback?.(this, compositionType)
}
const isUpgraded = child.matches(':defined')
if (isUpgraded) trigger()
else customElements.whenDefined(child.tagName.toLowerCase()).then(trigger)
}
[triggerChildUncomposedCallback as any](child: CompositionTracker, compositionType: CompositionType) {
// If we detected the discrepancy, return, the slotchange handler will rerun this appropriately.
if (child.#discrepancy) return
child.__composedParent = null
// We don't need to defer here like we did in
// triggerChildComposedCallback because if an element is uncomposed,
// it won't load anything even if its class gets defined later.
this.childUncomposedCallback?.(child, compositionType)
child.uncomposedCallback?.(this, compositionType)
}
// COMPOSED TREE TRACKING: This is called in certain cases when slotted
// children may have changed, f.e. when a slot was added to this element, or
// when a child slot of this element has had assigned nodes changed
// (slotchange).
__handleSlottedChildren(slot: HTMLSlotElement) {
const diff = this.__getSlottedChildDifference(slot)
const {removed} = diff
for (let l = removed.length, i = 0; i < l; i += 1) {
const removedNode = removed[i]
if (!(removedNode instanceof CompositionTracker)) continue
removedNode.slottedParent = null
// The node may have already been deleted, and
// __distributedChildren set to undefined, in the `added`
// for-loop of another slot.
if (this.slottedChildren) {
this.slottedChildren.delete(removedNode)
if (this.slottedChildren.size) this.slottedChildren = null
}
this.#this[triggerChildUncomposedCallback](removedNode, 'slot')
}
const {added} = diff
for (let l = added.length, i = 0; i < l; i += 1) {
const addedNode = added[i]
if (!(addedNode instanceof CompositionTracker)) continue
// Keep track of the final distribution of a node.
//
// If the given slot is assigned to another
// slot, then this logic will run again for the next slot on
// that next slot's slotchange, so we remove the slotted
// node from the previous distributedParent and add it to the next
// one. If we don't do this, then the slotted node will
// exist in multiple distributedChildren lists when there is a
// chain of assigned slots. For more info, see
// https://github.com/w3c/webcomponents/issues/611
const distributedParent = addedNode.slottedParent
if (distributedParent) {
const distributedChildren = distributedParent.slottedChildren
if (distributedChildren) {
distributedChildren.delete(addedNode)
if (!distributedChildren.size) distributedParent.slottedChildren = null
}
}
// The node is now slotted to `this` element.
addedNode.slottedParent = this
if (!this.slottedChildren) this.slottedChildren = new Set()
this.slottedChildren.add(addedNode)
// This is true then the reaction order is incorrect due to the
// order of slot change events.
//
// This discrepancy detection is only for slot composition
// right now. We need to add more tests to see if this is a
// problem with other composition types, and possbly
// combinations of composition types (f.e. uncomposed from a
// shadow root host, then composed to a slot parent, etc).
if (addedNode.__composedParent) addedNode.#discrepancy = true
this.#this[triggerChildComposedCallback](addedNode, 'slot')
}
// If there is the detected discrepancy for any of the added nodes,
// run uncomposed and composed reactions again, in that order. This
// fixes the edge case with composition causing composed to run
// before uncomposed when a node is moved to another slot (causing
// the rendering to break) due to slotchange ordering issues as with
// MutationObserver, described in
// https://github.com/whatwg/dom/issues/1111. More info in
// __getSlottedChildDifference.
//
// We will improve this by using Oxford Harrison's `realdom` library
// at https://github.com/webqit/realdom, which allows us to react to
// DOM mutations in a reliable way synchronously in the
// always-correct order (by patching all the DOM-mutating APIs such
// as appendChild, innerHTML, etc).
queueMicrotask(() => {
for (let l = added.length, i = 0; i < l; i += 1) {
const addedNode = added[i]
if (!(addedNode instanceof CompositionTracker)) continue
// if (addedNode.isConnected && !addedNode.__isComposed && addedNode.isComposed) {
if (addedNode.isConnected && addedNode.#discrepancy) {
// addedNode.recompose()
addedNode.#discrepancy = false
this.#this[triggerChildUncomposedCallback](addedNode, 'slot')
this.#this[triggerChildComposedCallback](addedNode, 'slot')
}
}
})
}
// COMPOSED TREE TRACKING: Get the difference between the last assigned
// elements and current assigned elements of a child slot of this element.
__getSlottedChildDifference(slot: HTMLSlotElement): SlotDiff {
const bruteForceMethod = true
if (bruteForceMethod) {
//////////////////////
// This method behaves *more* correct (not fully) than the other
// method, but does extra work because it runs unslotted
// reactions for *all* previous nodes, and then slotted
// reactions for *all* current nodes even if any of those nodes
// were not removed and added, to be sure that we catch
// synchronous changes where the same node was both removed and
// added or similar. We are not able to see all the mutations
// like we can with MutationObserver.
//
// This method might not catch cases when a node is added and
// then removed in the same tick. It might also not run
// reactions in a correct order across multiple slots (f.e.
// given a node removed from one slot then added to another, the
// slot that received the node may have its callback ran first
// and added reactions will fire, then the slot that had the
// node removed may have its *after*, causing the net effect on
// the node to be removed), which is the same problems as with
// MutationObserver callbacks described in
// https://github.com/whatwg/dom/issues/1111.
//
// Discussion: https://github.com/WICG/webcomponents/issues/1042
//////////////////////
const previousNodes = this.__previousSlotAssignedNodes.get(slot) ?? []
const newNodes = this.#getCurrentAssignedNodes(slot)
this.__previousSlotAssignedNodes.set(slot, [...newNodes])
return {removed: previousNodes, added: newNodes}
} else {
//////////////////////
// This method is potentially more optimized because it does a
// diff, and runs reactions only for nodes that were detected to
// actually be added or removed, but it fails to detect nodes
// that were both removed and added in the same tick because
// `slotchange` is synchronous and we do not have a way to see
// all mutation records, we can only see the current set of
// slotted nodes with slot.assignedNodes.
//////////////////////
const previousNodes = this.__previousSlotAssignedNodes.get(slot) ?? []
const newNodes = this.#getCurrentAssignedNodes(slot)
// Save the newNodes to be used as the previousNodes for next time
// (clone it so the following in-place modification doesn't ruin any
// assumptions in the next round).
this.__previousSlotAssignedNodes.set(slot, [...newNodes])
const diff: SlotDiff = {added: newNodes, removed: []}
for (let i = 0, l = previousNodes.length; i < l; i += 1) {
const oldNode = previousNodes[i]!
const newIndex = newNodes.indexOf(oldNode)
// if it exists in the previousNodes but not the newNodes, then
// the node was removed.
if (!(newIndex >= 0)) diff.removed.push(oldNode)
// otherwise the node wasn't added or removed.
else newNodes.splice(i, 1)
}
// The remaining nodes in newNodes must have been added.
return diff
}
}
#getCurrentAssignedNodes(slot: HTMLSlotElement) {
// If this slot is assigned to another slot, then we don't consider any
// of the slot's assigned nodes as being slotted to the current element,
// because instead they are slotted to an element further down in the
// composed tree where this slot is assigned to.
//
// Special case for Scenes: we don't care if slot children of a Scene
// distribute to a deeper slot, because a Scene's ShadowRoot is for the rendering
// implementation and not the user's distribution, so we only want to detect
// elements slotted directly to the Scene in that case.
// TODO filtering should be done by subclasses
// TODO move filtering to parent
return !this.isScene && slot.assignedSlot ? [] : slot.assignedElements({flatten: true})
}
traverseComposed(visitor: (el: CompositionTracker) => void, waitForUpgrade = false): Promise<void> | void {
visitor(this)
if (!waitForUpgrade) {
for (const child of this._composedChildren) child.traverseComposed(visitor, waitForUpgrade)
return
}
// If waitForUpgrade is true, we make a promise chain so that traversal
// order is still the same as when waitForUpgrade is false.
let promise: Promise<any> = Promise.resolve()
for (const child of this._composedChildren) {
const isUpgraded = child.matches(':defined')
if (isUpgraded) {
promise = promise!.then(() => child.traverseComposed(visitor, waitForUpgrade))
} else {
promise = promise!
.then(() => customElements.whenDefined(child.tagName.toLowerCase()))
.then(() => child.traverseComposed(visitor, waitForUpgrade))
}
}
return promise
}
}
}
export type CompositionType = 'root' | 'slot' | 'actual'
const shadowHosts: WeakSet<Element> = new WeakSet()
if (isDomEnvironment()) {
const original = Element.prototype.attachShadow
Element.prototype.attachShadow = function attachShadow(...args) {
const result = original.apply(this, args)
shadowHosts.add(this)
return result
}
}
export function hasShadow(el: Element): boolean {
return shadowHosts.has(el)
}
export function getComposedParent(el: HTMLElement): HTMLElement | null {
const parent = el.parentNode as ShadowRoot | Element | null
if (parent instanceof HTMLSlotElement) {
let slot = parent
// If el is a child of a <slot> element (i.e. el is a slot's default
// content), then return null if the slot has anything slotted to it in
// which case default content does not participate in the composed tree.
if (slot.assignedElements({flatten: true}).length) return null
return getComposedParent(slot)
} else {
const parent = el.parentNode as ShadowRoot | Element | null
if (!parent) return null
if (parent instanceof ShadowRoot) return parent.host as HTMLElement
if (hasShadow(parent)) {
// If the parent has a ShadowRoot, but el is does not have an
// assigned node, it is not slotted therefore not in the composed
// tree.
if (!el.assignedSlot) return null
// Otherwise, if el is assigned to a slot, that slot might be
// further assigned to a deeper slot, and so on.
while (el.assignedSlot) el = el.assignedSlot
// So finally get the slot's composition parent.
return getComposedParent(el)
}
// Regular parent is the composed parent.
return parent as HTMLElement
}
}
type SlotDiff = {added: Node[]; removed: Node[]}