From 9904cdd79af5589c3b43e884e27fa2ce10721b58 Mon Sep 17 00:00:00 2001 From: Olavo Santos Date: Thu, 3 Sep 2026 10:13:31 -0500 Subject: [PATCH] Correct ChildNode replacement and insertion order --- .changeset/correct-child-replace-with.md | 5 + packages/polyfill/source/ChildNode.ts | 120 ++++- packages/polyfill/source/ParentNode.ts | 131 +++--- .../tests/child-node-before-after.test.ts | 421 ++++++++++++++++++ .../tests/child-node-replace-with.test.ts | 251 +++++++++++ 5 files changed, 858 insertions(+), 70 deletions(-) create mode 100644 .changeset/correct-child-replace-with.md create mode 100644 packages/polyfill/source/tests/child-node-before-after.test.ts create mode 100644 packages/polyfill/source/tests/child-node-replace-with.test.ts diff --git a/.changeset/correct-child-replace-with.md b/.changeset/correct-child-replace-with.md new file mode 100644 index 00000000..377a186e --- /dev/null +++ b/.changeset/correct-child-replace-with.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': patch +--- + +Make `ChildNode.replaceWith()`, `before()`, and `after()` validate all arguments before changing existing trees, preserve sibling argument order, and commit each operation before custom-element reactions run. diff --git a/packages/polyfill/source/ChildNode.ts b/packages/polyfill/source/ChildNode.ts index 4bf81ad5..9d76cd4f 100644 --- a/packages/polyfill/source/ChildNode.ts +++ b/packages/polyfill/source/ChildNode.ts @@ -1,7 +1,12 @@ -import {NEXT} from './constants.ts'; +import {HOST, NEXT, PARENT, PREV} from './constants.ts'; +import {performWithCustomElementReactions} from './custom-element-reactions.ts'; +import {performHookEffects, type HookEffect} from './hook-effects.ts'; import type {ParentNode} from './ParentNode.ts'; import {Node} from './Node.ts'; +export const INSERT_NODE = Symbol('insertNode'); +export const REPLACE_NODE = Symbol('replaceNode'); + export class ChildNode extends Node { remove() { const parent = this.parentNode; @@ -10,39 +15,120 @@ export class ChildNode extends Node { } replaceWith(...nodes: (Node | string)[]) { + const staged = stageNodes(nodes); const parent = this.parentNode; if (!parent) return; - // Anchor on the first following sibling that isn't itself being moved, so - // that replacing a node with one of its own siblings still has a reference - // node left to insert before. - let next = this[NEXT]; - while (next && nodes.includes(next)) next = next[NEXT]; - parent.removeChild(this); - for (const node of nodes) { - parent.insertBefore(toNode(parent, node), next); - } + + return performChildNodeMutation((hookEffects) => { + validateNodesForInsertion(parent, staged); + + let next = this[NEXT]; + while (next && staged.includes(next)) next = next[NEXT]; + + const replacement = convertNodesIntoNode(parent, staged, hookEffects); + if (this.parentNode === parent) { + parent[REPLACE_NODE](replacement, this, hookEffects); + } else { + parent[INSERT_NODE](replacement, next, hookEffects); + } + }); } before(...nodes: (Node | string)[]) { + const staged = stageNodes(nodes); const parent = this.parentNode; if (!parent) return; - for (const node of nodes) { - parent.insertBefore(toNode(parent, node), this); - } + + return performChildNodeMutation((hookEffects) => { + validateNodesForInsertion(parent, staged); + + let previous = this[PREV]; + while (previous && staged.includes(previous)) previous = previous[PREV]; + + const node = convertNodesIntoNode(parent, staged, hookEffects); + parent[INSERT_NODE]( + node, + previous ? previous[NEXT] : parent.firstChild, + hookEffects, + ); + }); } after(...nodes: (Node | string)[]) { + const staged = stageNodes(nodes); const parent = this.parentNode; if (!parent) return; - const next = this[NEXT]; - for (const node of nodes) { - parent.insertBefore(toNode(parent, node), next); - } + + return performChildNodeMutation((hookEffects) => { + validateNodesForInsertion(parent, staged); + + let next = this[NEXT]; + while (next && staged.includes(next)) next = next[NEXT]; + + const node = convertNodesIntoNode(parent, staged, hookEffects); + parent[INSERT_NODE](node, next, hookEffects); + }); } } +function performChildNodeMutation( + mutation: (hookEffects: HookEffect[]) => void, +) { + return performWithCustomElementReactions(() => { + const hookEffects: HookEffect[] = []; + try { + mutation(hookEffects); + } catch (error) { + try { + performHookEffects(hookEffects); + } catch {} + throw error; + } + performHookEffects(hookEffects); + }); +} + +function stageNodes(nodes: (Node | string)[]) { + return nodes.map((node) => (node instanceof Node ? node : String(node))); +} + export function toNode(parent: ParentNode, node: Node | any) { if (node instanceof Node) return node; const ownerDocument = parent.ownerDocument; return ownerDocument.createTextNode(String(node)); } + +function validateNodesForInsertion( + parent: ParentNode, + nodes: (Node | string)[], +) { + for (const node of nodes) { + if (!(node instanceof Node)) continue; + + let ancestor: Node | null = parent; + while (ancestor) { + if (ancestor === node) { + throw Error( + 'cannot insert a node into itself or one of its descendants', + ); + } + ancestor = ancestor[PARENT] ?? ancestor[HOST]; + } + } +} + +function convertNodesIntoNode( + parent: ParentNode, + nodes: (Node | string)[], + hookEffects: HookEffect[], +): Node { + const convertedNodes: Node[] = []; + for (const node of nodes) convertedNodes.push(toNode(parent, node)); + if (convertedNodes.length === 1) return convertedNodes[0]!; + + const fragment = parent.ownerDocument.createDocumentFragment(); + for (const node of convertedNodes) { + fragment[INSERT_NODE](node, null, hookEffects); + } + return fragment; +} diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index 18787c38..bb757a2f 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -12,7 +12,7 @@ import { } from './constants.ts'; import type {Node} from './Node.ts'; import type {Element} from './Element.ts'; -import {ChildNode, toNode} from './ChildNode.ts'; +import {ChildNode, INSERT_NODE, REPLACE_NODE, toNode} from './ChildNode.ts'; import {NodeList} from './NodeList.ts'; import {querySelectorAll, querySelector} from './selectors.ts'; import {isElementNode, selfAndDescendants} from './shared.ts'; @@ -51,24 +51,22 @@ export class ParentNode extends ChildNode { readonly children = new NodeList(); appendChild(child: T) { - return performWithCustomElementReactions(() => { - this.insertInto(child, null); - return child; - }); + return performWithCustomElementReactions(() => + this[INSERT_NODE](child, null), + ); } insertBefore(child: T, ref?: Node | null) { - return performWithCustomElementReactions(() => { - this.insertInto(child, ref || null); - return child; - }); + return performWithCustomElementReactions(() => + this[INSERT_NODE](child, ref || null), + ); } append(...nodes: (Node | string)[]) { return performWithCustomElementReactions(() => { for (const child of nodes) { if (child == null) continue; - this.insertInto(toNode(this, child), null); + this[INSERT_NODE](toNode(this, child), null); } }); } @@ -78,7 +76,7 @@ export class ParentNode extends ChildNode { const before = this.firstChild; for (const child of nodes) { if (child == null) continue; - this.insertInto(toNode(this, child), before); + this[INSERT_NODE](toNode(this, child), before); } }); } @@ -91,7 +89,7 @@ export class ParentNode extends ChildNode { } for (const child of nodes) { if (child == null) continue; - this.insertInto(toNode(this, child), null); + this[INSERT_NODE](toNode(this, child), null); } }); } @@ -126,52 +124,59 @@ export class ParentNode extends ChildNode { } replaceChild(newChild: Node, oldChild: Node) { - return performWithCustomElementReactions(() => { - if (oldChild.parentNode !== this) { - throw Error('reference node is not a child of this parent'); - } + return performWithCustomElementReactions(() => + this[REPLACE_NODE](newChild, oldChild), + ); + } + + [REPLACE_NODE](newChild: Node, oldChild: Node, hookEffects?: HookEffect[]) { + if (oldChild.parentNode !== this) { + throw Error('reference node is not a child of this parent'); + } - const previous = oldChild[PREV]; - const next = oldChild[NEXT]; - this.validateInsertion(newChild, next); - - const insertion = this.prepareInsertion(newChild); - const removedNodes = this[IS_CONNECTED] - ? selfAndDescendants(oldChild) - : undefined; - const insertionRoots = new Set(insertion.map(({node}) => node)); - let before = next; - while (before && insertionRoots.has(before)) before = before[NEXT]; - - const oldChildIndex = this.detachChild(oldChild); - if (removedNodes) { - for (const node of removedNodes) node[IS_CONNECTED] = false; - - const removedNodeSet = new Set(removedNodes); - for (const prepared of insertion) { - if (removedNodeSet.has(prepared.node)) { - prepared.shouldDisconnect = false; - } + const previous = oldChild[PREV]; + const next = oldChild[NEXT]; + this.validateInsertion(newChild, next); + + const insertion = this.prepareInsertion(newChild); + const removedNodes = this[IS_CONNECTED] + ? selfAndDescendants(oldChild) + : undefined; + const insertionRoots = new Set(insertion.map(({node}) => node)); + let before = next; + while (before && insertionRoots.has(before)) before = before[NEXT]; + + const oldChildIndex = this.detachChild(oldChild); + if (removedNodes) { + for (const node of removedNodes) node[IS_CONNECTED] = false; + + const removedNodeSet = new Set(removedNodes); + for (const prepared of insertion) { + if (removedNodeSet.has(prepared.node)) { + prepared.shouldDisconnect = false; } } + } - this.commitInsertion(insertion, before); - const destinationIsConnected = this[IS_CONNECTED]; + this.commitInsertion(insertion, before); + const destinationIsConnected = this[IS_CONNECTED]; - this.queueRemovalMutationRecord(this, oldChild, previous, next); - this.queueInsertionMutationRecords(insertion); + this.queueRemovalMutationRecord(this, oldChild, previous, next); + this.queueInsertionMutationRecords(insertion); - if (removedNodes) { - this.enqueueTreeReactions(removedNodes, 'disconnectedCallback'); - } - this.enqueueInsertionReactions(insertion, destinationIsConnected); - performHookEffects([ + if (removedNodes) { + this.enqueueTreeReactions(removedNodes, 'disconnectedCallback'); + } + this.enqueueInsertionReactions(insertion, destinationIsConnected); + this.performOrCollectHookEffects( + [ ...this.collectRemovalHookEffects(oldChild, oldChildIndex), ...this.collectInsertionHookEffects(insertion), - ]); + ], + hookEffects, + ); - return oldChild; - }); + return oldChild; } querySelectorAll(selector: string) { @@ -182,9 +187,14 @@ export class ParentNode extends ChildNode { return querySelector(this, selector); } - private insertInto(child: Node, before: Node | null) { + [INSERT_NODE]( + child: T, + before: Node | null, + hookEffects?: HookEffect[], + ) { this.validateInsertion(child, before); - this.insertIntoValidated(child, before); + this.insertIntoValidated(child, before, hookEffects); + return child; } private validateInsertion(child: Node, before: Node | null) { @@ -203,7 +213,11 @@ export class ParentNode extends ChildNode { } } - private insertIntoValidated(child: Node, before: Node | null) { + private insertIntoValidated( + child: Node, + before: Node | null, + hookEffects?: HookEffect[], + ) { if (child === before) before = child[NEXT]; const insertion = this.prepareInsertion(child); @@ -211,7 +225,18 @@ export class ParentNode extends ChildNode { const destinationIsConnected = this[IS_CONNECTED]; this.queueInsertionMutationRecords(insertion); this.enqueueInsertionReactions(insertion, destinationIsConnected); - performHookEffects(this.collectInsertionHookEffects(insertion)); + this.performOrCollectHookEffects( + this.collectInsertionHookEffects(insertion), + hookEffects, + ); + } + + private performOrCollectHookEffects( + effects: HookEffect[], + hookEffects?: HookEffect[], + ) { + if (hookEffects) hookEffects.push(...effects); + else performHookEffects(effects); } private prepareInsertion(child: Node) { diff --git a/packages/polyfill/source/tests/child-node-before-after.test.ts b/packages/polyfill/source/tests/child-node-before-after.test.ts new file mode 100644 index 00000000..47611f41 --- /dev/null +++ b/packages/polyfill/source/tests/child-node-before-after.test.ts @@ -0,0 +1,421 @@ +import {beforeEach, describe, expect, it} from 'vitest'; + +import {HOOKS, HTMLElement as PolyfillHTMLElement, Window} from '../index.ts'; + +let polyfillWindow: Window; + +beforeEach(() => { + polyfillWindow = new Window(); + Window.setGlobalThis(polyfillWindow); +}); + +describe('ChildNode.before', () => { + it('preserves adjacent siblings and the receiver in argument order', () => { + const parent = document.createElement('div'); + const leading = document.createElement('span'); + const previous = document.createElement('em'); + const receiver = document.createElement('strong'); + const replacement = document.createElement('i'); + const after = document.createElement('b'); + parent.append(leading, previous, receiver, after); + + receiver.before(previous, replacement, receiver); + + expect([...parent.childNodes]).toEqual([ + leading, + previous, + replacement, + receiver, + after, + ]); + expect(leading.nextSibling).toBe(previous); + expect(previous.nextSibling).toBe(replacement); + expect(replacement.nextSibling).toBe(receiver); + expect(receiver.nextSibling).toBe(after); + }); + + it('commits every argument before running connected callbacks', () => { + let parent: HTMLElement; + let observedChildren: unknown[] = []; + + class BeforeObserver extends PolyfillHTMLElement { + connectedCallback() { + observedChildren = [...parent.childNodes]; + } + } + + polyfillWindow.customElements.define( + 'before-observer', + BeforeObserver as unknown as CustomElementConstructor, + ); + parent = document.createElement('div'); + const receiver = document.createElement('em'); + const after = document.createElement('strong'); + parent.append(receiver, after); + document.body.appendChild(parent); + const first = document.createElement('before-observer'); + const second = document.createElement('span'); + + receiver.before(first, second); + + expect(observedChildren).toEqual([first, second, receiver, after]); + expect([...parent.childNodes]).toEqual([first, second, receiver, after]); + }); + + it('commits arguments before disconnecting a moved custom element', () => { + const parent = document.createElement('div'); + const receiver = document.createElement('em'); + const trailing = document.createElement('strong'); + const second = document.createElement('span'); + const reactions: string[] = []; + let observedChildren: unknown[] = []; + + class MovingBeforeElement extends PolyfillHTMLElement { + connectedCallback() { + reactions.push('connected'); + } + + disconnectedCallback() { + reactions.push('disconnected'); + observedChildren = [...parent.childNodes]; + } + } + + polyfillWindow.customElements.define( + 'moving-before-element', + MovingBeforeElement as unknown as CustomElementConstructor, + ); + + const first = document.createElement('moving-before-element'); + parent.append(receiver, trailing); + document.body.append(parent, first); + reactions.length = 0; + + receiver.before(first, second); + + expect([...parent.childNodes]).toEqual([first, second, receiver, trailing]); + expect(observedChildren).toEqual([first, second, receiver, trailing]); + expect(reactions).toEqual(['disconnected', 'connected']); + }); + + it('does not move earlier arguments when a later conversion throws', () => { + let callbacks = 0; + + class BeforeReplacement extends PolyfillHTMLElement { + connectedCallback() { + callbacks += 1; + } + + disconnectedCallback() { + callbacks += 1; + } + } + + polyfillWindow.customElements.define( + 'before-replacement', + BeforeReplacement as unknown as CustomElementConstructor, + ); + const parent = document.createElement('div'); + const receiver = document.createElement('em'); + parent.appendChild(receiver); + const source = document.createElement('div'); + const replacement = document.createElement('before-replacement'); + source.appendChild(replacement); + document.body.append(parent, source); + callbacks = 0; + const mutations: string[] = []; + polyfillWindow[HOOKS].removeChild = () => mutations.push('remove'); + polyfillWindow[HOOKS].insertChild = () => mutations.push('insert'); + const error = new Error('conversion failed'); + const throwingValue = { + toString() { + throw error; + }, + } as unknown as string; + + expect(() => receiver.before(replacement, throwingValue)).toThrow(error); + + expect([...document.body.childNodes]).toEqual([parent, source]); + expect([...parent.childNodes]).toEqual([receiver]); + expect([...source.childNodes]).toEqual([replacement]); + expect(replacement.parentNode).toBe(source); + expect(replacement.isConnected).toBe(true); + expect(mutations).toEqual([]); + expect(callbacks).toBe(0); + }); + + it('commits every argument before rethrowing a lifecycle error', () => { + const error = new Error('connected callback failed'); + let secondCallbackRan = false; + + class ThrowingBeforeElement extends PolyfillHTMLElement { + connectedCallback() { + throw error; + } + } + + class SecondBeforeElement extends PolyfillHTMLElement { + connectedCallback() { + secondCallbackRan = true; + } + } + + polyfillWindow.customElements.define( + 'throwing-before', + ThrowingBeforeElement as unknown as CustomElementConstructor, + ); + polyfillWindow.customElements.define( + 'second-before', + SecondBeforeElement as unknown as CustomElementConstructor, + ); + const parent = document.createElement('div'); + const receiver = document.createElement('em'); + parent.appendChild(receiver); + document.body.appendChild(parent); + const first = document.createElement('throwing-before'); + const second = document.createElement('second-before'); + + expect(() => receiver.before(first, second)).toThrow(error); + + expect([...parent.childNodes]).toEqual([first, second, receiver]); + expect(secondCallbackRan).toBe(true); + }); +}); + +describe('ChildNode.after', () => { + it('preserves the receiver and adjacent siblings in argument order', () => { + const parent = document.createElement('div'); + const before = document.createElement('span'); + const receiver = document.createElement('em'); + const next = document.createElement('strong'); + const replacement = document.createElement('i'); + const trailing = document.createElement('b'); + parent.append(before, receiver, next, trailing); + + receiver.after(receiver, replacement, next); + + expect([...parent.childNodes]).toEqual([ + before, + receiver, + replacement, + next, + trailing, + ]); + expect(before.nextSibling).toBe(receiver); + expect(receiver.nextSibling).toBe(replacement); + expect(replacement.nextSibling).toBe(next); + expect(next.nextSibling).toBe(trailing); + }); + + it('commits every argument before running connected callbacks', () => { + let parent: HTMLElement; + let observedChildren: unknown[] = []; + + class AfterObserver extends PolyfillHTMLElement { + connectedCallback() { + observedChildren = [...parent.childNodes]; + } + } + + polyfillWindow.customElements.define( + 'after-observer', + AfterObserver as unknown as CustomElementConstructor, + ); + parent = document.createElement('div'); + const before = document.createElement('span'); + const receiver = document.createElement('em'); + const after = document.createElement('strong'); + parent.append(before, receiver, after); + document.body.appendChild(parent); + const first = document.createElement('after-observer'); + const second = document.createElement('i'); + + receiver.after(first, second); + + expect(observedChildren).toEqual([before, receiver, first, second, after]); + expect([...parent.childNodes]).toEqual([ + before, + receiver, + first, + second, + after, + ]); + }); + + it('commits arguments before disconnecting a moved custom element', () => { + const parent = document.createElement('div'); + const receiver = document.createElement('em'); + const trailing = document.createElement('strong'); + const second = document.createElement('span'); + const reactions: string[] = []; + let observedChildren: unknown[] = []; + + class MovingAfterElement extends PolyfillHTMLElement { + connectedCallback() { + reactions.push('connected'); + } + + disconnectedCallback() { + reactions.push('disconnected'); + observedChildren = [...parent.childNodes]; + } + } + + polyfillWindow.customElements.define( + 'moving-after-element', + MovingAfterElement as unknown as CustomElementConstructor, + ); + + const first = document.createElement('moving-after-element'); + parent.append(receiver, trailing); + document.body.append(parent, first); + reactions.length = 0; + + receiver.after(first, second); + + expect([...parent.childNodes]).toEqual([receiver, first, second, trailing]); + expect(observedChildren).toEqual([receiver, first, second, trailing]); + expect(reactions).toEqual(['disconnected', 'connected']); + }); + + it('rejects a cyclic later argument before mutating connected trees', () => { + let callbacks = 0; + + class AfterReplacement extends PolyfillHTMLElement { + connectedCallback() { + callbacks += 1; + } + + disconnectedCallback() { + callbacks += 1; + } + } + + polyfillWindow.customElements.define( + 'after-replacement', + AfterReplacement as unknown as CustomElementConstructor, + ); + const parent = document.createElement('div'); + const receiver = document.createElement('em'); + const trailing = document.createElement('strong'); + parent.append(receiver, trailing); + const source = document.createElement('div'); + const replacement = document.createElement('after-replacement'); + source.appendChild(replacement); + document.body.append(parent, source); + callbacks = 0; + const mutations: string[] = []; + polyfillWindow[HOOKS].removeChild = () => mutations.push('remove'); + polyfillWindow[HOOKS].insertChild = () => mutations.push('insert'); + + expect(() => receiver.after(replacement, parent)).toThrow(); + + expect([...document.body.childNodes]).toEqual([parent, source]); + expect([...parent.childNodes]).toEqual([receiver, trailing]); + expect([...source.childNodes]).toEqual([replacement]); + expect(replacement.parentNode).toBe(source); + expect(replacement.isConnected).toBe(true); + expect(mutations).toEqual([]); + expect(callbacks).toBe(0); + }); +}); + +describe.each(['before', 'after', 'replaceWith'] as const)( + 'ChildNode.%s prevalidation', + (method) => { + it('converts arguments for a detached receiver', () => { + const receiver = document.createElement('span'); + let conversions = 0; + const value = { + toString() { + conversions += 1; + return 'converted'; + }, + }; + + (receiver as any)[method](value); + + expect(conversions).toBe(1); + expect(receiver.parentNode).toBeNull(); + }); + + it('uses the receiver parent after argument conversion', () => { + const originalParent = document.createElement('div'); + const convertedParent = document.createElement('section'); + const receiver = document.createElement('span'); + originalParent.appendChild(receiver); + const value = { + toString() { + convertedParent.appendChild(receiver); + return 'converted'; + }, + }; + + (receiver as any)[method](value); + + expect(originalParent.childNodes).toHaveLength(0); + if (method === 'before') { + expect([...convertedParent.childNodes]).toEqual([ + expect.objectContaining({data: 'converted'}), + receiver, + ]); + } else if (method === 'after') { + expect([...convertedParent.childNodes]).toEqual([ + receiver, + expect.objectContaining({data: 'converted'}), + ]); + } else { + expect([...convertedParent.childNodes]).toEqual([ + expect.objectContaining({data: 'converted'}), + ]); + expect(receiver.parentNode).toBeNull(); + } + }); + + it('converts earlier arguments before rejecting a later cycle', () => { + const parent = document.createElement('div'); + const receiver = document.createElement('span'); + parent.appendChild(receiver); + const conversionError = new TypeError('conversion failed'); + let conversions = 0; + let thrown: unknown; + const value = { + toString() { + conversions++; + throw conversionError; + }, + }; + + try { + (receiver as any)[method](value, parent); + } catch (error) { + thrown = error; + } + + expect(conversions).toBe(1); + expect(thrown).toBe(conversionError); + expect([...parent.childNodes]).toEqual([receiver]); + }); + + it('rejects template-host cycles before moving earlier arguments', () => { + const holder = document.createElement('div'); + const template = document.createElement('template'); + const receiver = document.createElement('span'); + const source = document.createElement('div'); + const movable = document.createElement('em'); + holder.appendChild(template); + template.content.appendChild(receiver); + source.appendChild(movable); + const hooks: string[] = []; + polyfillWindow[HOOKS].removeChild = () => hooks.push('remove'); + polyfillWindow[HOOKS].insertChild = () => hooks.push('insert'); + + expect(() => (receiver as any)[method](movable, template)).toThrow(); + + expect([...holder.childNodes]).toEqual([template]); + expect([...template.content.childNodes]).toEqual([receiver]); + expect([...source.childNodes]).toEqual([movable]); + expect(hooks).toEqual([]); + }); + }, +); diff --git a/packages/polyfill/source/tests/child-node-replace-with.test.ts b/packages/polyfill/source/tests/child-node-replace-with.test.ts new file mode 100644 index 00000000..737e5a5c --- /dev/null +++ b/packages/polyfill/source/tests/child-node-replace-with.test.ts @@ -0,0 +1,251 @@ +import {beforeEach, describe, expect, it} from 'vitest'; + +import {HOOKS, HTMLElement as PolyfillHTMLElement, Window} from '../index.ts'; + +let polyfillWindow: Window; + +beforeEach(() => { + polyfillWindow = new Window(); + Window.setGlobalThis(polyfillWindow); +}); + +describe('ChildNode.replaceWith', () => { + it('inserts multiple nodes and strings in argument order', () => { + const parent = document.createElement('div'); + const receiver = document.createElement('span'); + const existingNext = document.createElement('em'); + const trailing = document.createElement('strong'); + const first = document.createElement('i'); + const last = document.createElement('b'); + parent.append(receiver, existingNext, trailing); + + receiver.replaceWith(first, existingNext, 'middle', last); + + const children = [...parent.childNodes]; + expect(children).toEqual([ + first, + existingNext, + expect.objectContaining({data: 'middle'}), + last, + trailing, + ]); + expect(children[1]!.nextSibling).toBe(children[2]); + expect(children[2]!.nextSibling).toBe(last); + expect(receiver.parentNode).toBeNull(); + }); + + it('preserves argument order when the receiver is a replacement', () => { + const parent = document.createElement('div'); + const receiver = document.createElement('span'); + const replacement = document.createElement('em'); + const after = document.createElement('strong'); + parent.append(receiver, after); + + receiver.replaceWith(replacement, receiver); + + expect([...parent.childNodes]).toEqual([replacement, receiver, after]); + expect(replacement.nextSibling).toBe(receiver); + expect(receiver.previousSibling).toBe(replacement); + expect(receiver.nextSibling).toBe(after); + }); + + it('rejects a cyclic first argument without mutating the tree', () => { + let reactions = 0; + + class CyclicReceiver extends PolyfillHTMLElement { + disconnectedCallback() { + reactions += 1; + } + } + + polyfillWindow.customElements.define( + 'cyclic-receiver', + CyclicReceiver as unknown as CustomElementConstructor, + ); + const parent = document.createElement('div'); + const before = document.createElement('span'); + const receiver = document.createElement('cyclic-receiver'); + const after = document.createElement('strong'); + parent.append(before, receiver, after); + document.body.appendChild(parent); + const mutations: string[] = []; + polyfillWindow[HOOKS].removeChild = () => mutations.push('remove'); + polyfillWindow[HOOKS].insertChild = () => mutations.push('insert'); + + expect(() => receiver.replaceWith(parent)).toThrow(); + + expect([...document.body.childNodes]).toEqual([parent]); + expect([...parent.childNodes]).toEqual([before, receiver, after]); + expect(before.nextSibling).toBe(receiver); + expect(receiver.previousSibling).toBe(before); + expect(receiver.nextSibling).toBe(after); + expect(after.previousSibling).toBe(receiver); + expect(mutations).toEqual([]); + expect(reactions).toBe(0); + }); + + it('rejects a cyclic later argument before mutating connected trees', () => { + let callbacks = 0; + + class ConnectedReplacement extends PolyfillHTMLElement { + connectedCallback() { + callbacks += 1; + } + + disconnectedCallback() { + callbacks += 1; + } + } + + polyfillWindow.customElements.define( + 'connected-replacement', + ConnectedReplacement as unknown as CustomElementConstructor, + ); + const parent = document.createElement('div'); + const before = document.createElement('span'); + const receiver = document.createElement('em'); + const after = document.createElement('strong'); + parent.append(before, receiver, after); + const source = document.createElement('div'); + const replacement = document.createElement('connected-replacement'); + const sourceSibling = document.createElement('i'); + source.append(replacement, sourceSibling); + document.body.append(parent, source); + callbacks = 0; + const mutations: string[] = []; + polyfillWindow[HOOKS].removeChild = () => mutations.push('remove'); + polyfillWindow[HOOKS].insertChild = () => mutations.push('insert'); + + expect(() => receiver.replaceWith(replacement, parent)).toThrow(); + + expect(parent.parentNode).toBe(document.body); + expect([...document.body.childNodes]).toEqual([parent, source]); + expect([...parent.childNodes]).toEqual([before, receiver, after]); + expect(before.nextSibling).toBe(receiver); + expect(receiver.previousSibling).toBe(before); + expect(receiver.nextSibling).toBe(after); + expect(after.previousSibling).toBe(receiver); + expect([...source.childNodes]).toEqual([replacement, sourceSibling]); + expect(replacement.parentNode).toBe(source); + expect(replacement.isConnected).toBe(true); + expect(mutations).toEqual([]); + expect(callbacks).toBe(0); + }); + + it('commits replacements before disconnecting a moved custom element', () => { + const parent = document.createElement('div'); + const receiver = document.createElement('em'); + const second = document.createElement('span'); + const reactions: string[] = []; + let observedChildren: unknown[] = []; + + class MovingElement extends PolyfillHTMLElement { + connectedCallback() { + reactions.push('connected'); + } + + disconnectedCallback() { + reactions.push('disconnected'); + observedChildren = [...parent.childNodes]; + } + } + + polyfillWindow.customElements.define( + 'moving-element', + MovingElement as unknown as CustomElementConstructor, + ); + + const first = document.createElement('moving-element'); + parent.appendChild(receiver); + document.body.append(parent, first); + reactions.length = 0; + + receiver.replaceWith(first, second); + + expect([...parent.childNodes]).toEqual([first, second]); + expect(observedChildren).toEqual([first, second]); + expect(reactions).toEqual(['disconnected', 'connected']); + }); + + it('commits links before removal hooks trigger nested reactions', () => { + const parent = document.createElement('div'); + const receiver = document.createElement('em'); + const second = document.createElement('span'); + const events: string[] = []; + let hookObservedChildren: unknown[] = []; + + class HookMovedElement extends PolyfillHTMLElement { + static observedAttributes = ['data-state']; + + connectedCallback() { + events.push('connected'); + } + + disconnectedCallback() { + events.push('disconnected'); + } + + attributeChangedCallback() { + events.push('attribute'); + } + } + + polyfillWindow.customElements.define( + 'hook-moved-element', + HookMovedElement as unknown as CustomElementConstructor, + ); + + const first = document.createElement('hook-moved-element'); + parent.appendChild(receiver); + document.body.append(parent, first); + events.length = 0; + polyfillWindow[HOOKS].removeChild = (_parent, child) => { + if (child !== first) return; + hookObservedChildren = [...parent.childNodes]; + first.setAttribute('data-state', 'moved'); + events.push('hook-return'); + }; + + receiver.replaceWith(first, second); + + expect(hookObservedChildren).toEqual([first, second]); + expect(events).toEqual([ + 'disconnected', + 'connected', + 'attribute', + 'hook-return', + ]); + }); + + it('commits every replacement before running reentrant reactions', () => { + let parent: HTMLElement; + let first: HTMLElement; + let second: HTMLElement; + let observedChildren: unknown[] = []; + + class ReentrantReceiver extends PolyfillHTMLElement { + disconnectedCallback() { + observedChildren = [...parent.childNodes]; + second.remove(); + } + } + + polyfillWindow.customElements.define( + 'reentrant-receiver', + ReentrantReceiver as unknown as CustomElementConstructor, + ); + parent = document.createElement('div'); + const receiver = document.createElement('reentrant-receiver'); + first = document.createElement('span'); + second = document.createElement('strong'); + parent.appendChild(receiver); + document.body.appendChild(parent); + + receiver.replaceWith(first, second); + + expect(observedChildren).toEqual([first, second]); + expect([...parent.childNodes]).toEqual([first]); + expect(receiver.parentNode).toBeNull(); + expect(second.parentNode).toBeNull(); + }); +});