From fc245ba6edd7622c4cccbd98e744d6934f04ab46 Mon Sep 17 00:00:00 2001 From: Olavo Santos Date: Wed, 2 Sep 2026 10:05:58 -0500 Subject: [PATCH] Adopt full subtrees during cross-document insertion --- .changeset/adopt-inserted-subtrees.md | 5 + packages/polyfill/source/ChildNode.ts | 2 + packages/polyfill/source/Document.ts | 27 +- packages/polyfill/source/ParentNode.ts | 163 +++-- packages/polyfill/source/shared.ts | 49 ++ .../tests/adopt-inserted-subtrees.test.ts | 584 ++++++++++++++++++ 6 files changed, 774 insertions(+), 56 deletions(-) create mode 100644 .changeset/adopt-inserted-subtrees.md create mode 100644 packages/polyfill/source/tests/adopt-inserted-subtrees.test.ts diff --git a/.changeset/adopt-inserted-subtrees.md b/.changeset/adopt-inserted-subtrees.md new file mode 100644 index 00000000..b6342298 --- /dev/null +++ b/.changeset/adopt-inserted-subtrees.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': patch +--- + +Adopt complete subtrees, including initialized template content, during cross-document insertion so descendant and attribute mutations use the destination document. diff --git a/packages/polyfill/source/ChildNode.ts b/packages/polyfill/source/ChildNode.ts index 9d76cd4f..2095a1fd 100644 --- a/packages/polyfill/source/ChildNode.ts +++ b/packages/polyfill/source/ChildNode.ts @@ -5,6 +5,7 @@ import type {ParentNode} from './ParentNode.ts'; import {Node} from './Node.ts'; export const INSERT_NODE = Symbol('insertNode'); +export const PREFLIGHT_INSERTIONS = Symbol('preflightInsertions'); export const REPLACE_NODE = Symbol('replaceNode'); export class ChildNode extends Node { @@ -127,6 +128,7 @@ function convertNodesIntoNode( if (convertedNodes.length === 1) return convertedNodes[0]!; const fragment = parent.ownerDocument.createDocumentFragment(); + fragment[PREFLIGHT_INSERTIONS](convertedNodes); for (const node of convertedNodes) { fragment[INSERT_NODE](node, null, hookEffects); } diff --git a/packages/polyfill/source/Document.ts b/packages/polyfill/source/Document.ts index f0292eb2..31a44dd9 100644 --- a/packages/polyfill/source/Document.ts +++ b/packages/polyfill/source/Document.ts @@ -19,7 +19,7 @@ import { import type {Window} from './Window.ts'; import type {Node} from './Node.ts'; import {Event} from './Event.ts'; -import {ParentNode} from './ParentNode.ts'; +import {ParentNode, removeChildForAdoption} from './ParentNode.ts'; import {Element} from './Element.ts'; import {SVGElement} from './SVGElement.ts'; import {Text} from './Text.ts'; @@ -27,8 +27,9 @@ import {Comment} from './Comment.ts'; import {DocumentFragment} from './DocumentFragment.ts'; import {HTMLTemplateElement} from './HTMLTemplateElement.ts'; import { - isParentNode, + adoptNodes, cloneNode, + collectAdoptionSnapshot, getElementById as findElementById, getElementsByClassName as findElementsByClassName, getElementsByTagName as findElementsByTagName, @@ -36,6 +37,7 @@ import { import {HTMLBodyElement} from './HTMLBodyElement.ts'; import {HTMLHeadElement} from './HTMLHeadElement.ts'; import {HTMLHtmlElement} from './HTMLHtmlElement.ts'; +import {performWithCustomElementReactions} from './custom-element-reactions.ts'; export class Document extends ParentNode { nodeType: NodeType = NODE_TYPE_DOCUMENT; @@ -125,10 +127,13 @@ export class Document extends ParentNode { adoptNode(node: Node) { if (node[OWNER_DOCUMENT] === this) return node; - node.parentNode?.removeChild(node); - adoptNode(node, this); - - return node; + const adoption = collectAdoptionSnapshot(node); + return performWithCustomElementReactions(() => { + const parent = node.parentNode; + if (parent) removeChildForAdoption(parent, node, adoption, this); + else adoptNodes(adoption.nodes, this); + return node; + }); } } @@ -185,13 +190,3 @@ export function setupElement( return element; } - -export function adoptNode(node: Node, document: Document) { - node[OWNER_DOCUMENT] = document; - - if (isParentNode(node)) { - for (const child of node.childNodes) { - adoptNode(child, document); - } - } -} diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index bb757a2f..e58304e7 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -12,10 +12,21 @@ import { } from './constants.ts'; import type {Node} from './Node.ts'; import type {Element} from './Element.ts'; -import {ChildNode, INSERT_NODE, REPLACE_NODE, toNode} from './ChildNode.ts'; +import { + ChildNode, + INSERT_NODE, + PREFLIGHT_INSERTIONS, + REPLACE_NODE, + toNode, +} from './ChildNode.ts'; import {NodeList} from './NodeList.ts'; import {querySelectorAll, querySelector} from './selectors.ts'; -import {isElementNode, selfAndDescendants} from './shared.ts'; +import { + adoptNodes, + collectAdoptionSnapshot, + isElementNode, + selfAndDescendants, +} from './shared.ts'; import { childListObserversActive, mutationNodeList, @@ -34,6 +45,7 @@ import { interface PreparedInsertionRoot { node: Node; nodes: Node[] | undefined; + adoption: Node[] | undefined; shouldDisconnect: boolean; source?: { parent: ParentNode; @@ -109,7 +121,7 @@ export class ParentNode extends ChildNode { : undefined; const previousSibling = child[PREV]; const nextSibling = child[NEXT]; - const childNodesIndex = this.detachChild(child); + const childNodesIndex = detachChild(this, child); if (disconnectedNodes) { for (const node of disconnectedNodes) node[IS_CONNECTED] = false; @@ -146,7 +158,7 @@ export class ParentNode extends ChildNode { let before = next; while (before && insertionRoots.has(before)) before = before[NEXT]; - const oldChildIndex = this.detachChild(oldChild); + const oldChildIndex = detachChild(this, oldChild); if (removedNodes) { for (const node of removedNodes) node[IS_CONNECTED] = false; @@ -197,6 +209,13 @@ export class ParentNode extends ChildNode { return child; } + [PREFLIGHT_INSERTIONS](children: Node[]) { + for (const child of children) { + this.validateInsertion(child, null); + this.prepareInsertion(child); + } + } + private validateInsertion(child: Node, before: Node | null) { if (before && before.parentNode !== this) { throw Error('reference node is not a child of this parent'); @@ -253,17 +272,23 @@ export class ParentNode extends ChildNode { } const destinationIsConnected = this[IS_CONNECTED]; + const ownerDocument = this[OWNER_DOCUMENT]; const insertion: PreparedInsertionRoot[] = []; for (const node of roots) { const wasConnected = node[IS_CONNECTED]; - insertion.push({ - node, - nodes: - wasConnected || destinationIsConnected - ? selfAndDescendants(node) - : undefined, - shouldDisconnect: wasConnected, - }); + const shouldTraverse = wasConnected || destinationIsConnected; + let nodes: Node[] | undefined; + let adoption: Node[] | undefined; + + if (node[OWNER_DOCUMENT] === ownerDocument) { + if (shouldTraverse) nodes = selfAndDescendants(node); + } else { + const snapshot = collectAdoptionSnapshot(node); + adoption = snapshot.nodes; + if (shouldTraverse) nodes = snapshot.treeNodes; + } + + insertion.push({node, nodes, adoption, shouldDisconnect: wasConnected}); } return insertion; @@ -280,7 +305,7 @@ export class ParentNode extends ChildNode { const nextSibling = prepared.node[NEXT]; prepared.source = { parent: sourceParent, - index: sourceParent.detachChild(prepared.node), + index: detachChild(sourceParent, prepared.node), previousSibling, nextSibling, }; @@ -294,6 +319,11 @@ export class ParentNode extends ChildNode { prepared.nextSibling = attached.nextSibling; } + const ownerDocument = this[OWNER_DOCUMENT]; + for (const {adoption} of insertion) { + if (adoption) adoptNodes(adoption, ownerDocument); + } + const isConnected = this[IS_CONNECTED]; for (const {nodes} of insertion) { if (nodes) { @@ -302,27 +332,6 @@ export class ParentNode extends ChildNode { } } - private detachChild(child: Node) { - const previous = child[PREV]; - const next = child[NEXT]; - if (previous) previous[NEXT] = next; - else this[CHILD] = next; - if (next) next[PREV] = previous; - - const childNodesIndex = this.childNodes.indexOf(child); - this.childNodes.splice(childNodesIndex, 1); - - if (isElementNode(child)) { - this.children.splice(this.children.indexOf(child), 1); - } - - child[PARENT] = null; - child[NEXT] = null; - child[PREV] = null; - - return childNodesIndex; - } - private attachChild(child: Node, before: Node | null) { if (before) { const previous = before[PREV]; @@ -347,7 +356,6 @@ export class ParentNode extends ChildNode { const isElement = isElementNode(child); child[PARENT] = this; - child[OWNER_DOCUMENT] = this[OWNER_DOCUMENT]; let insertIndex: number; if (before) { @@ -494,11 +502,86 @@ export class ParentNode extends ChildNode { nodes: Node[], callbackName: 'connectedCallback' | 'disconnectedCallback', ) { - for (const node of nodes) { - const callback = (node as any)[callbackName]; - if (typeof callback === 'function') { - enqueueCustomElementReaction(node, () => callback.call(node)); - } + enqueueTreeReactions(nodes, callbackName); + } +} + +export function removeChildForAdoption( + parent: ParentNode, + child: Node, + adoption: ReturnType, + destination: Node['ownerDocument'], +) { + if (child.parentNode !== parent) throw Error(`not a child of this node`); + + const disconnectedNodes = parent[IS_CONNECTED] + ? adoption.treeNodes + : undefined; + const previousSibling = child[PREV]; + const nextSibling = child[NEXT]; + const childNodesIndex = detachChild(parent, child); + + if (disconnectedNodes) { + for (const node of disconnectedNodes) node[IS_CONNECTED] = false; + } + adoptNodes(adoption.nodes, destination); + + if (childListObserversActive) { + queueMutationRecord({ + type: 'childList', + target: parent, + removedNodes: mutationNodeList(child), + previousSibling, + nextSibling, + }); + } + + if (disconnectedNodes) { + enqueueTreeReactions(disconnectedNodes, 'disconnectedCallback'); + } + + const effects: HookEffect[] = []; + if ( + parent.nodeType === NODE_TYPE_ELEMENT && + !isCoveredByPendingPublication(parent) + ) { + effects.push([ + () => + (parent as any)[HOOKS].removeChild?.(parent, child, childNodesIndex), + ]); + } + performHookEffects(effects); +} + +function detachChild(parent: ParentNode, child: Node) { + const previous = child[PREV]; + const next = child[NEXT]; + if (previous) previous[NEXT] = next; + else parent[CHILD] = next; + if (next) next[PREV] = previous; + + const childNodesIndex = parent.childNodes.indexOf(child); + parent.childNodes.splice(childNodesIndex, 1); + + if (isElementNode(child)) { + parent.children.splice(parent.children.indexOf(child), 1); + } + + child[PARENT] = null; + child[NEXT] = null; + child[PREV] = null; + + return childNodesIndex; +} + +function enqueueTreeReactions( + nodes: Node[], + callbackName: 'connectedCallback' | 'disconnectedCallback', +) { + for (const node of nodes) { + const callback = (node as any)[callbackName]; + if (typeof callback === 'function') { + enqueueCustomElementReaction(node, () => callback.call(node)); } } } diff --git a/packages/polyfill/source/shared.ts b/packages/polyfill/source/shared.ts index 1af2e830..65257b3d 100644 --- a/packages/polyfill/source/shared.ts +++ b/packages/polyfill/source/shared.ts @@ -11,6 +11,7 @@ import { NAME, HTML_NAMESPACE, asciiLowercase, + CONTENT, splitOnASCIIWhitespace, } from './constants.ts'; import type {Document} from './Document.ts'; @@ -21,6 +22,7 @@ import type {ParentNode} from './ParentNode.ts'; import type {Element} from './Element.ts'; import type {CharacterData} from './CharacterData.ts'; import type {Text} from './Text.ts'; +import type {HTMLTemplateElement} from './HTMLTemplateElement.ts'; import { MATCHER_CLASS, MATCHER_ID, @@ -52,6 +54,53 @@ export function isParentNode(node: Node): node is ParentNode { return 'appendChild' in node; } +export function collectAdoptionSnapshot(root: Node) { + const nodes: Node[] = []; + const pendingRoots = [root]; + const pending = new Set(pendingRoots); + const visited = new Set(); + let treeNodes: Node[] | undefined; + + while (pendingRoots.length > 0) { + const currentRoot = pendingRoots.pop()!; + pending.delete(currentRoot); + if (visited.has(currentRoot)) continue; + + const currentTreeNodes: Node[] = []; + for (const node of selfAndDescendants(currentRoot)) { + if (visited.has(node)) continue; + + visited.add(node); + nodes.push(node); + currentTreeNodes.push(node); + if (!isElementNode(node)) continue; + + const attributes = node[ATTRIBUTES]; + if (attributes) { + for (const attribute of attributes) { + if (visited.has(attribute)) continue; + visited.add(attribute); + nodes.push(attribute); + } + } + + const content = (node as HTMLTemplateElement)[CONTENT]; + if (content && !visited.has(content) && !pending.has(content)) { + pending.add(content); + pendingRoots.push(content); + } + } + + treeNodes ??= currentTreeNodes; + } + + return {nodes, treeNodes: treeNodes!}; +} + +export function adoptNodes(nodes: Node[], document: Document) { + for (const node of nodes) node[OWNER_DOCUMENT] = document; +} + export function cloneNode( node: Node, deep?: boolean, diff --git a/packages/polyfill/source/tests/adopt-inserted-subtrees.test.ts b/packages/polyfill/source/tests/adopt-inserted-subtrees.test.ts new file mode 100644 index 00000000..a01ccf22 --- /dev/null +++ b/packages/polyfill/source/tests/adopt-inserted-subtrees.test.ts @@ -0,0 +1,584 @@ +import {describe, expect, it, vi} from 'vitest'; + +import {HOOKS, Window} from '../index.ts'; +import {CHILD, CONTENT, PARENT} from '../constants.ts'; +import type {HTMLTemplateElement} from '../HTMLTemplateElement.ts'; +import {adoptNodes, collectAdoptionSnapshot} from '../shared.ts'; + +const DEEP_TEMPLATE_CONTENT_DEPTH = 6_000; + +describe('cross-document insertion', () => { + it('adopts a nested subtree and its attached attributes', () => { + const sourceWindow = new Window(); + const destinationWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = destinationWindow.document; + + const root = sourceDocument.createElement('section'); + const descendant = sourceDocument.createElement('span'); + const text = sourceDocument.createTextNode('before'); + root.setAttribute('data-root', 'root'); + descendant.setAttribute('data-descendant', 'before'); + descendant.appendChild(text); + root.appendChild(descendant); + + const rootAttribute = root.attributes.getNamedItem('data-root')!; + const descendantAttribute = + descendant.attributes.getNamedItem('data-descendant')!; + const sourceSetAttribute = vi.fn(); + const sourceSetText = vi.fn(); + const sourceInsertChild = vi.fn(); + const destinationSetAttribute = vi.fn(); + const destinationSetText = vi.fn(); + const destinationInsertChild = vi.fn(); + + sourceWindow[HOOKS] = { + setAttribute: sourceSetAttribute, + setText: sourceSetText, + insertChild: sourceInsertChild, + }; + destinationWindow[HOOKS] = { + setAttribute: destinationSetAttribute, + setText: destinationSetText, + insertChild: destinationInsertChild, + }; + + destinationDocument.body.appendChild(root); + + expect(root.ownerDocument).toBe(destinationDocument); + expect(descendant.ownerDocument).toBe(destinationDocument); + expect(text.ownerDocument).toBe(destinationDocument); + expect(rootAttribute.ownerDocument).toBe(destinationDocument); + expect(descendantAttribute.ownerDocument).toBe(destinationDocument); + + descendant.setAttribute('data-added', 'value'); + descendantAttribute.value = 'after'; + text.data = 'after'; + descendant.appendChild(destinationDocument.createElement('strong')); + + expect(sourceSetAttribute).not.toHaveBeenCalled(); + expect(sourceSetText).not.toHaveBeenCalled(); + expect(sourceInsertChild).not.toHaveBeenCalled(); + expect(destinationSetAttribute).toHaveBeenCalledTimes(2); + expect(destinationSetText).toHaveBeenCalledTimes(1); + expect(destinationInsertChild).toHaveBeenCalledTimes(2); + }); + + it('adopts initialized template content and routes later mutations through the destination hooks', () => { + const sourceWindow = new Window(); + const destinationWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = destinationWindow.document; + const template = sourceDocument.createElement( + 'template', + ) as HTMLTemplateElement; + const content = template.content; + const contentElement = sourceDocument.createElement('span'); + const text = sourceDocument.createTextNode('before'); + contentElement.setAttribute('data-content', 'before'); + contentElement.appendChild(text); + content.appendChild(contentElement); + + const attribute = contentElement.attributes.getNamedItem('data-content')!; + const sourceSetAttribute = vi.fn(); + const sourceSetText = vi.fn(); + const sourceInsertChild = vi.fn(); + const destinationSetAttribute = vi.fn(); + const destinationSetText = vi.fn(); + const destinationInsertChild = vi.fn(); + + sourceWindow[HOOKS] = { + setAttribute: sourceSetAttribute, + setText: sourceSetText, + insertChild: sourceInsertChild, + }; + destinationWindow[HOOKS] = { + setAttribute: destinationSetAttribute, + setText: destinationSetText, + insertChild: destinationInsertChild, + }; + + destinationDocument.body.appendChild(template); + + for (const node of [template, content, contentElement, text, attribute]) { + expect(node.ownerDocument).toBe(destinationDocument); + } + + contentElement.setAttribute('data-added', 'value'); + attribute.value = 'after'; + text.data = 'after'; + contentElement.appendChild(destinationDocument.createElement('strong')); + + expect(sourceSetAttribute).not.toHaveBeenCalled(); + expect(sourceSetText).not.toHaveBeenCalled(); + expect(sourceInsertChild).not.toHaveBeenCalled(); + expect(destinationSetAttribute).toHaveBeenCalledTimes(2); + expect(destinationSetText).toHaveBeenCalledTimes(1); + expect(destinationInsertChild).toHaveBeenCalledTimes(2); + }); + + it('adopts initialized template content with Document.adoptNode', () => { + const sourceWindow = new Window(); + const destinationWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = destinationWindow.document; + const template = sourceDocument.createElement( + 'template', + ) as HTMLTemplateElement; + const content = template.content; + const contentElement = sourceDocument.createElement('span'); + const text = sourceDocument.createTextNode('before'); + contentElement.setAttribute('data-content', 'before'); + contentElement.appendChild(text); + content.appendChild(contentElement); + + const attribute = contentElement.attributes.getNamedItem('data-content')!; + const sourceSetAttribute = vi.fn(); + const sourceSetText = vi.fn(); + const sourceInsertChild = vi.fn(); + const destinationSetAttribute = vi.fn(); + const destinationSetText = vi.fn(); + const destinationInsertChild = vi.fn(); + + sourceWindow[HOOKS] = { + setAttribute: sourceSetAttribute, + setText: sourceSetText, + insertChild: sourceInsertChild, + }; + destinationWindow[HOOKS] = { + setAttribute: destinationSetAttribute, + setText: destinationSetText, + insertChild: destinationInsertChild, + }; + + destinationDocument.adoptNode(template); + + for (const node of [template, content, contentElement, text, attribute]) { + expect(node.ownerDocument).toBe(destinationDocument); + } + + contentElement.setAttribute('data-added', 'value'); + attribute.value = 'after'; + text.data = 'after'; + contentElement.appendChild(destinationDocument.createElement('strong')); + + expect(sourceSetAttribute).not.toHaveBeenCalled(); + expect(sourceSetText).not.toHaveBeenCalled(); + expect(sourceInsertChild).not.toHaveBeenCalled(); + expect(destinationSetAttribute).toHaveBeenCalledTimes(2); + expect(destinationSetText).toHaveBeenCalledTimes(1); + expect(destinationInsertChild).toHaveBeenCalledTimes(1); + }); + + it('does not initialize untouched template content during adoption', () => { + const sourceDocument = new Window().document; + const destinationDocument = new Window().document; + const template = sourceDocument.createElement( + 'template', + ) as HTMLTemplateElement; + + expect(template[CONTENT]).toBeUndefined(); + + destinationDocument.body.appendChild(template); + + expect(template.ownerDocument).toBe(destinationDocument); + expect(template[CONTENT]).toBeUndefined(); + }); + + it('snapshots and adopts a malformed direct template host cycle once', () => { + const sourceDocument = new Window().document; + const destinationDocument = new Window().document; + const template = sourceDocument.createElement( + 'template', + ) as HTMLTemplateElement; + const content = template.content; + + content[CHILD] = template; + template[PARENT] = content; + + const snapshot = collectAdoptionSnapshot(template); + + expect(snapshot.treeNodes).toEqual([template]); + expect(snapshot.nodes).toEqual([template, content]); + expect(template.ownerDocument).toBe(sourceDocument); + expect(content.ownerDocument).toBe(sourceDocument); + + adoptNodes(snapshot.nodes, destinationDocument); + + expect(template.ownerDocument).toBe(destinationDocument); + expect(content.ownerDocument).toBe(destinationDocument); + }); + + it('snapshots and adopts a malformed mutual template host cycle once', () => { + const sourceDocument = new Window().document; + const destinationDocument = new Window().document; + const first = sourceDocument.createElement( + 'template', + ) as HTMLTemplateElement; + const second = sourceDocument.createElement( + 'template', + ) as HTMLTemplateElement; + const firstContent = first.content; + const secondContent = second.content; + + firstContent[CHILD] = second; + second[PARENT] = firstContent; + secondContent[CHILD] = first; + first[PARENT] = secondContent; + + const snapshot = collectAdoptionSnapshot(first); + + expect(snapshot.treeNodes).toEqual([first]); + expect(snapshot.nodes).toEqual([ + first, + firstContent, + second, + secondContent, + ]); + for (const node of snapshot.nodes) { + expect(node.ownerDocument).toBe(sourceDocument); + } + + adoptNodes(snapshot.nodes, destinationDocument); + + for (const node of snapshot.nodes) { + expect(node.ownerDocument).toBe(destinationDocument); + } + }); + + it('iteratively adopts deeply nested template content', () => { + const sourceWindow = new Window(); + const destinationWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = destinationWindow.document; + const root = sourceDocument.createElement( + 'template', + ) as HTMLTemplateElement; + const templates = [root]; + const contents = []; + let leaf = root; + + for (let depth = 0; depth < DEEP_TEMPLATE_CONTENT_DEPTH; depth++) { + const content = leaf.content; + const nested = sourceDocument.createElement( + 'template', + ) as HTMLTemplateElement; + content.appendChild(nested); + contents.push(content); + templates.push(nested); + leaf = nested; + } + + const sourceInsertChild = vi.fn(); + const sourceSetAttribute = vi.fn(); + const destinationInsertChild = vi.fn(); + const destinationSetAttribute = vi.fn(); + sourceWindow[HOOKS] = { + insertChild: sourceInsertChild, + setAttribute: sourceSetAttribute, + }; + destinationWindow[HOOKS] = { + insertChild: destinationInsertChild, + setAttribute: destinationSetAttribute, + }; + + expect(leaf[CONTENT]).toBeUndefined(); + + destinationDocument.body.appendChild(root); + + for (const template of templates) { + expect(template.ownerDocument).toBe(destinationDocument); + } + for (const content of contents) { + expect(content.ownerDocument).toBe(destinationDocument); + } + expect(leaf[CONTENT]).toBeUndefined(); + + leaf.setAttribute('data-deep', 'adopted'); + + expect(sourceInsertChild).not.toHaveBeenCalled(); + expect(sourceSetAttribute).not.toHaveBeenCalled(); + expect(destinationInsertChild).toHaveBeenCalledTimes(1); + expect(destinationSetAttribute).toHaveBeenCalledTimes(1); + }, 20_000); + + it('preflights template content before changing insertion state', () => { + const sourceWindow = new Window(); + const destinationWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = destinationWindow.document; + const source = sourceDocument.createElement('div'); + const destination = destinationDocument.createElement('div'); + const template = sourceDocument.createElement( + 'template', + ) as HTMLTemplateElement; + const content = template.content; + const contentElement = sourceDocument.createElement('span'); + contentElement.setAttribute('data-content', 'value'); + content.appendChild(contentElement); + source.appendChild(template); + sourceDocument.body.appendChild(source); + destinationDocument.body.appendChild(destination); + + const attribute = contentElement.attributes.getNamedItem('data-content')!; + const nodes = [template, content, contentElement, attribute]; + const originalOwnerDocuments = nodes.map((node) => node.ownerDocument); + const originalConnectivity = nodes.map((node) => node.isConnected); + const traversalError = new Error('template content traversal failed'); + Object.defineProperty(content, CHILD, { + get() { + throw traversalError; + }, + }); + + const sourceInsertChild = vi.fn(); + const sourceRemoveChild = vi.fn(); + const destinationInsertChild = vi.fn(); + const destinationRemoveChild = vi.fn(); + sourceWindow[HOOKS] = { + insertChild: sourceInsertChild, + removeChild: sourceRemoveChild, + }; + destinationWindow[HOOKS] = { + insertChild: destinationInsertChild, + removeChild: destinationRemoveChild, + }; + + expect(() => destination.appendChild(template)).toThrow(traversalError); + + expect([...source.childNodes]).toEqual([template]); + expect(destination.childNodes).toHaveLength(0); + expect(source.parentNode).toBe(sourceDocument.body); + expect(destination.parentNode).toBe(destinationDocument.body); + expect(source.isConnected).toBe(true); + expect(destination.isConnected).toBe(true); + expect(template.parentNode).toBe(source); + expect(template.previousSibling).toBeNull(); + expect(template.nextSibling).toBeNull(); + expect([...content.childNodes]).toEqual([contentElement]); + expect(contentElement.parentNode).toBe(content); + nodes.forEach((node, index) => { + expect(node.ownerDocument).toBe(originalOwnerDocuments[index]); + expect(node.isConnected).toBe(originalConnectivity[index]); + }); + expect(sourceInsertChild).not.toHaveBeenCalled(); + expect(sourceRemoveChild).not.toHaveBeenCalled(); + expect(destinationInsertChild).not.toHaveBeenCalled(); + expect(destinationRemoveChild).not.toHaveBeenCalled(); + }); + + it('preflights every compound replacement before moving any argument', () => { + const sourceWindow = new Window(); + const destinationWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = destinationWindow.document; + let reactions = 0; + + class MovingElement extends sourceWindow.HTMLElement { + connectedCallback() { + reactions += 1; + } + + disconnectedCallback() { + reactions += 1; + } + } + sourceWindow.customElements.define( + 'compound-moving-element', + MovingElement as unknown as CustomElementConstructor, + ); + + const source = sourceDocument.createElement('div'); + const first = sourceDocument.createElement('compound-moving-element'); + const template = sourceDocument.createElement( + 'template', + ) as HTMLTemplateElement; + const content = template.content; + const contentElement = sourceDocument.createElement('span'); + content.appendChild(contentElement); + source.append(first, template); + sourceDocument.body.appendChild(source); + + const destination = destinationDocument.createElement('div'); + const receiver = destinationDocument.createElement('em'); + destination.appendChild(receiver); + destinationDocument.body.appendChild(destination); + reactions = 0; + const nodes = [first, template, content, contentElement]; + const originalOwnerDocuments = nodes.map((node) => node.ownerDocument); + const originalConnectivity = nodes.map((node) => node.isConnected); + const traversalError = new Error('compound template traversal failed'); + Object.defineProperty(content, CHILD, { + get() { + throw traversalError; + }, + }); + + const sourceInsertChild = vi.fn(); + const sourceRemoveChild = vi.fn(); + const destinationInsertChild = vi.fn(); + const destinationRemoveChild = vi.fn(); + sourceWindow[HOOKS] = { + insertChild: sourceInsertChild, + removeChild: sourceRemoveChild, + }; + destinationWindow[HOOKS] = { + insertChild: destinationInsertChild, + removeChild: destinationRemoveChild, + }; + + expect(() => receiver.replaceWith(first, template)).toThrow(traversalError); + + expect([...source.childNodes]).toEqual([first, template]); + expect([...destination.childNodes]).toEqual([receiver]); + expect(first.parentNode).toBe(source); + expect(template.parentNode).toBe(source); + expect([...content.childNodes]).toEqual([contentElement]); + expect(contentElement.parentNode).toBe(content); + nodes.forEach((node, index) => { + expect(node.ownerDocument).toBe(originalOwnerDocuments[index]); + expect(node.isConnected).toBe(originalConnectivity[index]); + }); + expect(sourceInsertChild).not.toHaveBeenCalled(); + expect(sourceRemoveChild).not.toHaveBeenCalled(); + expect(destinationInsertChild).not.toHaveBeenCalled(); + expect(destinationRemoveChild).not.toHaveBeenCalled(); + expect(reactions).toBe(0); + }); + + it.each(['callback', 'hook'] as const)( + 'commits adoption before a removal %s appends a source child', + (source) => { + const sourceWindow = new Window(); + const destinationWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = destinationWindow.document; + let appended: ReturnType | undefined; + let root: ReturnType; + + if (source === 'callback') { + class MovingElement extends sourceWindow.HTMLElement { + disconnectedCallback() { + expect(this.ownerDocument).toBe(destinationDocument); + expect(this.parentNode).toBeNull(); + appended = sourceDocument.createElement('span'); + this.appendChild(appended); + } + } + sourceWindow.customElements.define( + 'moving-element', + MovingElement as unknown as CustomElementConstructor, + ); + root = sourceDocument.createElement('moving-element'); + } else { + root = sourceDocument.createElement('section'); + } + + sourceDocument.body.appendChild(root); + const sourceSetAttribute = vi.fn(); + const destinationSetAttribute = vi.fn(); + sourceWindow[HOOKS] = { + setAttribute: sourceSetAttribute, + removeChild: (_parent, child) => { + if (source !== 'hook' || child !== (root as any)) return; + expect(root.ownerDocument).toBe(destinationDocument); + expect(root.parentNode).toBeNull(); + appended = sourceDocument.createElement('span'); + root.appendChild(appended); + }, + }; + destinationWindow[HOOKS] = {setAttribute: destinationSetAttribute}; + + destinationDocument.adoptNode(root); + + expect(root.ownerDocument).toBe(destinationDocument); + expect(appended?.parentNode).toBe(root); + expect(appended?.ownerDocument).toBe(destinationDocument); + appended!.setAttribute('data-owner', 'destination'); + expect(sourceSetAttribute).not.toHaveBeenCalled(); + expect(destinationSetAttribute).toHaveBeenCalledOnce(); + }, + ); + + it.each(['callback', 'hook'] as const)( + 'preserves a third-document transfer from a removal %s', + (source) => { + const sourceWindow = new Window(); + const destinationWindow = new Window(); + const thirdWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = destinationWindow.document; + const thirdDocument = thirdWindow.document; + let child: ReturnType; + let root: ReturnType; + + if (source === 'callback') { + class TransferringElement extends sourceWindow.HTMLElement { + disconnectedCallback() { + expect(this.ownerDocument).toBe(destinationDocument); + thirdDocument.body.appendChild(child); + } + } + sourceWindow.customElements.define( + 'transferring-element', + TransferringElement as unknown as CustomElementConstructor, + ); + root = sourceDocument.createElement('transferring-element'); + } else { + root = sourceDocument.createElement('section'); + } + + child = sourceDocument.createElement('span'); + root.appendChild(child); + sourceDocument.body.appendChild(root); + const sourceSetAttribute = vi.fn(); + const destinationSetAttribute = vi.fn(); + const thirdSetAttribute = vi.fn(); + sourceWindow[HOOKS] = { + setAttribute: sourceSetAttribute, + removeChild: (_parent, removed) => { + if (source === 'hook' && removed === (root as any)) { + expect(root.ownerDocument).toBe(destinationDocument); + thirdDocument.body.appendChild(child); + } + }, + }; + destinationWindow[HOOKS] = {setAttribute: destinationSetAttribute}; + thirdWindow[HOOKS] = {setAttribute: thirdSetAttribute}; + + destinationDocument.adoptNode(root); + + expect(root.ownerDocument).toBe(destinationDocument); + expect(child.parentNode).toBe(thirdDocument.body); + expect(child.ownerDocument).toBe(thirdDocument); + child.setAttribute('data-owner', 'third'); + expect(sourceSetAttribute).not.toHaveBeenCalled(); + expect(destinationSetAttribute).not.toHaveBeenCalled(); + expect(thirdSetAttribute).toHaveBeenCalledOnce(); + }, + ); + + it('adopts every subtree inserted from a document fragment', () => { + const sourceDocument = new Window().document; + const destinationDocument = new Window().document; + const fragment = sourceDocument.createDocumentFragment(); + const first = sourceDocument.createElement('div'); + const second = sourceDocument.createElement('section'); + const descendant = sourceDocument.createElement('span'); + const text = sourceDocument.createTextNode('content'); + descendant.setAttribute('data-nested', 'value'); + descendant.appendChild(text); + second.appendChild(descendant); + fragment.append(first, second); + + const attribute = descendant.attributes.getNamedItem('data-nested')!; + + destinationDocument.body.appendChild(fragment); + + expect(fragment.ownerDocument).toBe(sourceDocument); + for (const node of [first, second, descendant, text, attribute]) { + expect(node.ownerDocument).toBe(destinationDocument); + } + expect(fragment.childNodes).toHaveLength(0); + }); +});