From 75dff193fd52fd35217492c19d673f176f04ad8d Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Thu, 17 Sep 2026 11:13:48 -0700 Subject: [PATCH 1/2] Detach adopted attributes from their elements --- packages/polyfill/source/Document.ts | 19 + packages/polyfill/source/NamedNodeMap.ts | 125 +++--- .../tests/adopt-attached-attribute.test.ts | 368 ++++++++++++++++++ 3 files changed, 466 insertions(+), 46 deletions(-) create mode 100644 packages/polyfill/source/tests/adopt-attached-attribute.test.ts diff --git a/packages/polyfill/source/Document.ts b/packages/polyfill/source/Document.ts index 4c74627b..e38ff3ab 100644 --- a/packages/polyfill/source/Document.ts +++ b/packages/polyfill/source/Document.ts @@ -3,6 +3,7 @@ import { NAME, PREFIX, NODE_TYPE_DOCUMENT, + NODE_TYPE_ATTRIBUTE, HTML_NAMESPACE, SVG_NAMESPACE, type NamespaceURI, @@ -19,6 +20,7 @@ import { } from './names.ts'; import type {Window} from './Window.ts'; import type {Node} from './Node.ts'; +import type {Attr} from './Attr.ts'; import {Event} from './Event.ts'; import {ParentNode, removeChildForAdoption} from './ParentNode.ts'; import {Element} from './Element.ts'; @@ -40,6 +42,7 @@ import {HTMLBodyElement} from './HTMLBodyElement.ts'; import {HTMLHeadElement} from './HTMLHeadElement.ts'; import {HTMLHtmlElement} from './HTMLHtmlElement.ts'; import {performWithCustomElementReactions} from './custom-element-reactions.ts'; +import {removeAttributeForAdoption} from './NamedNodeMap.ts'; export class Document extends ParentNode { nodeType: NodeType = NODE_TYPE_DOCUMENT; @@ -140,6 +143,22 @@ export class Document extends ParentNode { } adoptNode(node: Node) { + if (node.nodeType === NODE_TYPE_ATTRIBUTE) { + const attribute = node as Attr; + const ownerElement = attribute.ownerElement; + + if (ownerElement) { + return performWithCustomElementReactions(() => { + removeAttributeForAdoption(ownerElement.attributes, attribute, this); + return attribute; + }); + } + + if (attribute[OWNER_DOCUMENT] === this) return attribute; + adoptNodes([attribute], this); + return attribute; + } + if (node[OWNER_DOCUMENT] === this) return node; const adoption = collectAdoptionSnapshot(node); diff --git a/packages/polyfill/source/NamedNodeMap.ts b/packages/polyfill/source/NamedNodeMap.ts index ab3cdeaf..b24382e6 100644 --- a/packages/polyfill/source/NamedNodeMap.ts +++ b/packages/polyfill/source/NamedNodeMap.ts @@ -12,12 +12,14 @@ import { import {normalizeNamespace} from './names.ts'; import type {Attr} from './Attr.ts'; import type {Element} from './Element.ts'; +import type {Document} from './Document.ts'; import { attributeObserversActive, queueMutationRecord, } from './MutationObserver.ts'; import {performWithCustomElementReactions} from './custom-element-reactions.ts'; import {enqueueAttributeReaction} from './attribute-reactions.ts'; +import {adoptNodes} from './shared.ts'; export class NamedNodeMap { [CHILD]: Attr | null = null; @@ -84,7 +86,7 @@ export class NamedNodeMap { : qualifiedName; return performWithCustomElementReactions(() => - this.removeNamedItemImmediately((attr) => attr.name === normalizedName), + removeNamedItemImmediately(this, (attr) => attr.name === normalizedName), ); } @@ -93,57 +95,14 @@ export class NamedNodeMap { const normalizedLocalName = String(localName); return performWithCustomElementReactions(() => - this.removeNamedItemImmediately( + removeNamedItemImmediately( + this, (attr) => attr.localName === normalizedLocalName && attr[NS] === namespace, ), ); } - private removeNamedItemImmediately(matches: (attr: Attr) => boolean) { - const ownerElement = this[OWNER_ELEMENT]; - let attr = this[CHILD]; - let prev: typeof attr | null = null; - - while (attr != null) { - if (matches(attr)) { - if (prev) prev[NEXT] = attr[NEXT]; - if (this[CHILD] === attr) this[CHILD] = attr[NEXT]; - const oldValue = attr.value; - attr[NEXT] = null; - attr[OWNER_ELEMENT] = null; - - if (attributeObserversActive) { - queueMutationRecord({ - type: 'attributes', - target: ownerElement, - attributeName: attr.name, - attributeNamespace: attr[NS], - oldValue, - }); - } - ownerElement[HOOKS].removeAttribute?.( - ownerElement as any, - attr.name, - attr[NS], - ); - enqueueAttributeReaction( - ownerElement, - attr.localName, - oldValue, - null, - attr[NS], - ); - return attr; - } - - prev = attr; - attr = attr[NEXT]; - } - - return null; - } - setNamedItem(attr: Attr) { return performWithCustomElementReactions(() => this.setNamedItemImmediately(attr), @@ -246,3 +205,77 @@ export class NamedNodeMap { } } } + +function removeNamedItemImmediately( + attributes: NamedNodeMap, + matches: (attr: Attr) => boolean, + destination?: Document, +) { + const ownerElement = attributes[OWNER_ELEMENT]; + let attr = attributes[CHILD]; + let prev: typeof attr | null = null; + + while (attr != null) { + if (matches(attr)) { + const qualifiedName = attr.name; + const localName = attr.localName; + const namespace = attr[NS]; + const oldValue = attr.value; + + if (prev) prev[NEXT] = attr[NEXT]; + if (attributes[CHILD] === attr) attributes[CHILD] = attr[NEXT]; + attr[NEXT] = null; + attr[OWNER_ELEMENT] = null; + if (destination) adoptNodes([attr], destination); + + if (attributeObserversActive) { + queueMutationRecord({ + type: 'attributes', + target: ownerElement, + attributeName: qualifiedName, + attributeNamespace: namespace, + oldValue, + }); + } + ownerElement[HOOKS].removeAttribute?.( + ownerElement as any, + qualifiedName, + namespace, + ); + enqueueAttributeReaction( + ownerElement, + localName, + oldValue, + null, + namespace, + ); + return attr; + } + + prev = attr; + attr = attr[NEXT]; + } + + return null; +} + +/** @internal */ +export function removeAttributeForAdoption( + attributes: NamedNodeMap, + attribute: Attr, + destination: Document, +) { + const removed = removeNamedItemImmediately( + attributes, + (candidate) => candidate === attribute, + destination, + ); + + if (removed == null) { + throw new Error( + 'The owner element does not contain the adopted attribute.', + ); + } + + return removed; +} diff --git a/packages/polyfill/source/tests/adopt-attached-attribute.test.ts b/packages/polyfill/source/tests/adopt-attached-attribute.test.ts new file mode 100644 index 00000000..725f0cb3 --- /dev/null +++ b/packages/polyfill/source/tests/adopt-attached-attribute.test.ts @@ -0,0 +1,368 @@ +import {describe, expect, it, vi} from 'vitest'; + +import type {Attr} from '../Attr.ts'; +import {CHILD, HOOKS, NEXT} from '../constants.ts'; +import type {Element} from '../Element.ts'; +import {Window} from '../index.ts'; +import type {MutationRecord} from '../MutationObserver.ts'; + +const STATE_NAMESPACE = 'urn:state'; + +function expectRemovalRecord( + record: MutationRecord, + target: Element, + oldValue = 'initial', +) { + expect(record).toMatchObject({ + type: 'attributes', + target, + attributeName: 'state:mode', + attributeNamespace: STATE_NAMESPACE, + oldValue, + }); +} + +describe('Document.adoptNode() attached attributes', () => { + it('detaches an attached attribute during same-document adoption', () => { + const window = new Window(); + const {document} = window; + const element = document.createElement('div'); + element.setAttribute('data-state', 'initial'); + const attribute = element.attributes.getNamedItem('data-state')!; + const removeAttribute = vi.fn(() => { + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(document); + expect(attribute[NEXT]).toBeNull(); + }); + window[HOOKS] = {removeAttribute}; + + expect(document.adoptNode(attribute)).toBe(attribute); + + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(document); + expect(attribute[NEXT]).toBeNull(); + expect(element.hasAttribute('data-state')).toBe(false); + expect(element.attributes.getNamedItem('data-state')).toBeNull(); + expect(removeAttribute).toHaveBeenCalledOnce(); + expect(removeAttribute).toHaveBeenCalledWith(element, 'data-state', null); + }); + + it('adopts only the exact namespaced attribute and records its source removal', () => { + const sourceWindow = new Window(); + const destinationWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = destinationWindow.document; + const source = sourceDocument.createElement('div'); + source.setAttributeNS(STATE_NAMESPACE, 'state:mode', 'initial'); + source.setAttributeNS('urn:control', 'control:mode', 'retained'); + const attribute = source.attributes.getNamedItemNS( + STATE_NAMESPACE, + 'mode', + )!; + const control = source.attributes.getNamedItemNS('urn:control', 'mode')!; + const sourceRemoveAttribute = vi.fn(); + const destinationRemoveAttribute = vi.fn(); + sourceWindow[HOOKS] = {removeAttribute: sourceRemoveAttribute}; + destinationWindow[HOOKS] = {removeAttribute: destinationRemoveAttribute}; + const observer = new sourceWindow.MutationObserver(() => {}); + observer.observe(source, {attributes: true, attributeOldValue: true}); + + expect(destinationDocument.adoptNode(attribute)).toBe(attribute); + + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(destinationDocument); + expect(attribute[NEXT]).toBeNull(); + expect( + source.attributes.getNamedItemNS(STATE_NAMESPACE, 'mode'), + ).toBeNull(); + expect(source.getAttributeNS(STATE_NAMESPACE, 'mode')).toBeNull(); + expect(source.attributes.getNamedItemNS('urn:control', 'mode')).toBe( + control, + ); + expect(control.ownerElement).toBe(source); + expect(control.ownerDocument).toBe(sourceDocument); + expect(control.value).toBe('retained'); + expect(sourceRemoveAttribute).toHaveBeenCalledOnce(); + expect(sourceRemoveAttribute).toHaveBeenCalledWith( + source, + 'state:mode', + STATE_NAMESPACE, + ); + expect(destinationRemoveAttribute).not.toHaveBeenCalled(); + const records = observer.takeRecords(); + expect(records).toHaveLength(1); + expectRemovalRecord(records[0]!, source); + + const destination = destinationDocument.createElement('div'); + expect(destination.attributes.setNamedItemNS(attribute)).toBeNull(); + expect(attribute.ownerElement).toBe(destination); + expect(attribute.ownerDocument).toBe(destinationDocument); + expect(destination.getAttributeNS(STATE_NAMESPACE, 'mode')).toBe('initial'); + observer.disconnect(); + }); + + it.each(['hook', 'callback'] as const)( + 'preserves source reattachment from the removal %s', + (notification) => { + const sourceWindow = new Window(); + const destinationWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = destinationWindow.document; + let attribute: Attr; + let source: ReturnType; + + const reattach = () => { + expect( + source.attributes.getNamedItemNS(STATE_NAMESPACE, 'mode'), + ).toBeNull(); + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(destinationDocument); + expect(attribute[NEXT]).toBeNull(); + source.attributes.setNamedItemNS(attribute); + }; + + if (notification === 'callback') { + class ReattachingElement extends sourceWindow.HTMLElement { + static observedAttributes = ['mode']; + + attributeChangedCallback( + _name: string, + _oldValue: string | null, + newValue: string | null, + ) { + if (newValue == null) reattach(); + } + } + sourceWindow.customElements.define( + 'reattaching-element', + ReattachingElement as unknown as CustomElementConstructor, + ); + source = sourceDocument.createElement('reattaching-element'); + } else { + source = sourceDocument.createElement('div'); + } + + source.setAttributeNS(STATE_NAMESPACE, 'state:mode', 'initial'); + attribute = source.attributes.getNamedItemNS(STATE_NAMESPACE, 'mode')!; + sourceWindow[HOOKS] = { + removeAttribute: () => { + if (notification === 'hook') reattach(); + }, + }; + + destinationDocument.adoptNode(attribute); + + expect(attribute.ownerElement).toBe(source); + expect(attribute.ownerDocument).toBe(sourceDocument); + expect(source.attributes.getNamedItemNS(STATE_NAMESPACE, 'mode')).toBe( + attribute, + ); + }, + ); + + it.each(['hook', 'callback'] as const)( + 'preserves a detached third-document transfer from the removal %s', + (notification) => { + const sourceWindow = new Window(); + const destinationDocument = new Window().document; + const thirdDocument = new Window().document; + const sourceDocument = sourceWindow.document; + let attribute: Attr; + let source: ReturnType; + + const transfer = () => { + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(destinationDocument); + thirdDocument.adoptNode(attribute); + }; + + if (notification === 'callback') { + class TransferringElement extends sourceWindow.HTMLElement { + static observedAttributes = ['mode']; + + attributeChangedCallback( + _name: string, + _oldValue: string | null, + newValue: string | null, + ) { + if (newValue == null) transfer(); + } + } + sourceWindow.customElements.define( + 'transferring-element', + TransferringElement as unknown as CustomElementConstructor, + ); + source = sourceDocument.createElement('transferring-element'); + } else { + source = sourceDocument.createElement('div'); + } + + source.setAttributeNS(STATE_NAMESPACE, 'state:mode', 'initial'); + attribute = source.attributes.getNamedItemNS(STATE_NAMESPACE, 'mode')!; + sourceWindow[HOOKS] = { + removeAttribute: () => { + if (notification === 'hook') transfer(); + }, + }; + + destinationDocument.adoptNode(attribute); + + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(thirdDocument); + expect( + source.attributes.getNamedItemNS(STATE_NAMESPACE, 'mode'), + ).toBeNull(); + }, + ); + + it.each(['hook', 'callback'] as const)( + 'leaves committed ownership when the removal %s throws', + (notification) => { + const sourceWindow = new Window(); + const destinationDocument = new Window().document; + const sourceDocument = sourceWindow.document; + const error = new Error(`${notification} failed`); + let removalCallbackCount = 0; + + class ThrowingElement extends sourceWindow.HTMLElement { + static observedAttributes = ['mode']; + + attributeChangedCallback( + _name: string, + _oldValue: string | null, + newValue: string | null, + ) { + if (newValue != null) return; + removalCallbackCount++; + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(destinationDocument); + if (notification === 'callback') throw error; + } + } + sourceWindow.customElements.define( + 'throwing-element', + ThrowingElement as unknown as CustomElementConstructor, + ); + const source = sourceDocument.createElement('throwing-element'); + source.setAttributeNS(STATE_NAMESPACE, 'state:mode', 'initial'); + const attribute = source.attributes.getNamedItemNS( + STATE_NAMESPACE, + 'mode', + )!; + const observer = new sourceWindow.MutationObserver(() => {}); + observer.observe(source, {attributes: true, attributeOldValue: true}); + sourceWindow[HOOKS] = { + removeAttribute: () => { + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(destinationDocument); + if (notification === 'hook') throw error; + }, + }; + + expect(() => destinationDocument.adoptNode(attribute)).toThrow(error); + + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(destinationDocument); + expect(attribute[NEXT]).toBeNull(); + expect( + source.attributes.getNamedItemNS(STATE_NAMESPACE, 'mode'), + ).toBeNull(); + const records = observer.takeRecords(); + expect(records).toHaveLength(1); + expectRemovalRecord(records[0]!, source); + expect(removalCallbackCount).toBe(notification === 'callback' ? 1 : 0); + observer.disconnect(); + }, + ); + + it('queues the record before the hook and exposes destination ownership to every notification', () => { + const sourceWindow = new Window(); + const destinationDocument = new Window().document; + const sourceDocument = sourceWindow.document; + const events: string[] = []; + let attribute: Attr; + + class ObservingElement extends sourceWindow.HTMLElement { + static observedAttributes = ['mode']; + + attributeChangedCallback( + _name: string, + _oldValue: string | null, + newValue: string | null, + ) { + if (newValue != null) return; + events.push('callback'); + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(destinationDocument); + } + } + sourceWindow.customElements.define( + 'observing-element', + ObservingElement as unknown as CustomElementConstructor, + ); + const source = sourceDocument.createElement('observing-element'); + source.setAttributeNS(STATE_NAMESPACE, 'state:mode', 'initial'); + attribute = source.attributes.getNamedItemNS(STATE_NAMESPACE, 'mode')!; + const observer = new sourceWindow.MutationObserver(() => {}); + observer.observe(source, {attributes: true, attributeOldValue: true}); + sourceWindow[HOOKS] = { + removeAttribute: () => { + events.push('hook'); + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(destinationDocument); + const records = observer.takeRecords(); + expect(records).toHaveLength(1); + expectRemovalRecord(records[0]!, source); + }, + }; + + destinationDocument.adoptNode(attribute); + + expect(events).toEqual(['hook', 'callback']); + observer.disconnect(); + }); + + it('fails before transfer when the claimed owner map lacks the exact attribute', () => { + const sourceWindow = new Window(); + const destinationDocument = new Window().document; + const sourceDocument = sourceWindow.document; + const source = sourceDocument.createElement('div'); + source.setAttribute('state', 'initial'); + const attribute = source.attributes.getNamedItem('state')!; + source.attributes[CHILD] = null; + const removeAttribute = vi.fn(); + sourceWindow[HOOKS] = {removeAttribute}; + + expect(() => destinationDocument.adoptNode(attribute)).toThrow( + 'The owner element does not contain the adopted attribute.', + ); + expect(attribute.ownerElement).toBe(source); + expect(attribute.ownerDocument).toBe(sourceDocument); + expect(attribute[NEXT]).toBeNull(); + expect(removeAttribute).not.toHaveBeenCalled(); + }); + + it.each(['same-document', 'cross-document'] as const)( + 'keeps detached attribute adoption on the %s fast path without notifications', + (kind) => { + const sourceWindow = new Window(); + const destinationWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = + kind === 'same-document' ? sourceDocument : destinationWindow.document; + const source = sourceDocument.createElement('div'); + source.setAttribute('state', 'initial'); + const attribute = source.attributes.removeNamedItem('state')!; + const sourceRemoveAttribute = vi.fn(); + const destinationRemoveAttribute = vi.fn(); + sourceWindow[HOOKS] = {removeAttribute: sourceRemoveAttribute}; + destinationWindow[HOOKS] = {removeAttribute: destinationRemoveAttribute}; + + expect(destinationDocument.adoptNode(attribute)).toBe(attribute); + expect(attribute.ownerElement).toBeNull(); + expect(attribute.ownerDocument).toBe(destinationDocument); + expect(sourceRemoveAttribute).not.toHaveBeenCalled(); + expect(destinationRemoveAttribute).not.toHaveBeenCalled(); + }, + ); +}); From a91588ca31987894ca2a3decdb17dc9d78192b32 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Thu, 17 Sep 2026 11:13:55 -0700 Subject: [PATCH 2/2] Add attached Attr adoption changeset --- .changeset/adopt-attached-attributes.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/adopt-attached-attributes.md diff --git a/.changeset/adopt-attached-attributes.md b/.changeset/adopt-attached-attributes.md new file mode 100644 index 00000000..41ae02da --- /dev/null +++ b/.changeset/adopt-attached-attributes.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': patch +--- + +Detach attributes from their owner element when passed to `Document.adoptNode()`, including same-document adoption.