Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/adopt-inserted-subtrees.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/polyfill/source/ChildNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand Down
27 changes: 11 additions & 16 deletions packages/polyfill/source/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,25 @@ 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';
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,
} from './shared.ts';
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;
Expand Down Expand Up @@ -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;
});
}
}

Expand Down Expand Up @@ -185,13 +190,3 @@ export function setupElement<T extends Element>(

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);
}
}
}
163 changes: 123 additions & 40 deletions packages/polyfill/source/ParentNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,6 +45,7 @@ import {
interface PreparedInsertionRoot {
node: Node;
nodes: Node[] | undefined;
adoption: Node[] | undefined;
shouldDisconnect: boolean;
source?: {
parent: ParentNode;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand All @@ -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,
};
Expand All @@ -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) {
Expand All @@ -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];
Expand All @@ -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) {
Expand Down Expand Up @@ -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<typeof collectAdoptionSnapshot>,
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));
}
}
}
49 changes: 49 additions & 0 deletions packages/polyfill/source/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
NAME,
HTML_NAMESPACE,
asciiLowercase,
CONTENT,
splitOnASCIIWhitespace,
} from './constants.ts';
import type {Document} from './Document.ts';
Expand All @@ -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,
Expand Down Expand Up @@ -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<Node>(pendingRoots);
const visited = new Set<Node>();
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,
Expand Down
Loading
Loading