UNPKG

lume

Version:

Build next-level interactive web applications.

516 lines 26.4 kB
// 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 { isDomEnvironment, isScene } from './utils/isThisOrThat.js'; export const triggerChildComposedCallback = Symbol('triggerChildComposedCallback'); export const triggerChildUncomposedCallback = Symbol('triggerChildUncomposedCallback'); export function CompositionTracker(Base) { var _a; return class CompositionTracker extends Constructor(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. attachShadow(options) { 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() { return !!this.exposedShadowRoot; } // COMPOSED TREE TRACKING: get _isPossiblyDistributedToShadowRoot() { return this.isPossiblySlotted; } // COMPOSED TREE TRACKING: get _shadowRootParent() { return this.shadowParent; } get _shadowRootChildren() { if (!this.exposedShadowRoot) return []; return Array.from(this.exposedShadowRoot.children).filter((n) => n instanceof CompositionTracker); } // COMPOSED TREE TRACKING: Elements that are slotted to a slot that is // child of a ShadowRoot of this element. get _distributedShadowRootChildren() { const result = []; 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() { return this.slottedParent; } // COMPOSED TREE TRACKING: get _distributedChildren() { return this.slottedChildren ? [...this.slottedChildren] : null; } __composedParent = null; get composedParent() { 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() { let parent = 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; 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() { 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 instanceof CompositionTracker), ]; } } // COMPOSED TREE TRACKING: /** This element's ShadowRoot, if any (even if it is a closed shadow root, unlike the `shadowRoot` property) */ exposedShadowRoot; // 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; // 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 = 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 = 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 = null; #this = this; // 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) { // 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) { // 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() { if (this.__onChildSlotChange__) return this.__onChildSlotChange__; this.__onChildSlotChange__ = (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; this.__handleSlottedChildren(slot); }; return this.__onChildSlotChange__; } __onChildSlotChange__; #discrepancy = false; [triggerChildComposedCallback](child, 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](child, 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) { 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) { 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 = { 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) { // 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, waitForUpgrade = false) { 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.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; } }; } const shadowHosts = 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) { return shadowHosts.has(el); } export function getComposedParent(el) { const parent = el.parentNode; 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; if (!parent) return null; if (parent instanceof ShadowRoot) return parent.host; 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; } } //# sourceMappingURL=CompositionTracker.js.map