diff --git a/.changeset/named-node-map-properties.md b/.changeset/named-node-map-properties.md new file mode 100644 index 00000000..39b28759 --- /dev/null +++ b/.changeset/named-node-map-properties.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': minor +--- + +Add live indexed and named property access to `Element.attributes`, including correct attribute ownership when values are changed, removed, replaced, adopted, or moved between documents. diff --git a/packages/polyfill/source/Document.ts b/packages/polyfill/source/Document.ts index 131aeb57..d79b84e5 100644 --- a/packages/polyfill/source/Document.ts +++ b/packages/polyfill/source/Document.ts @@ -1,6 +1,7 @@ import { NS, NAME, + NODE_TYPE_ATTRIBUTE, NODE_TYPE_DOCUMENT, SVG_NAMESPACE, type NamespaceURI, @@ -11,6 +12,7 @@ import { } from './constants.ts'; import type {Window} from './Window.ts'; import type {Node} from './Node.ts'; +import type {Attr} from './Attr.ts'; import {getElementsByClassName as findElementsByClassName} from './getElementsByClassName.ts'; import {Event} from './Event.ts'; import {ParentNode} from './ParentNode.ts'; @@ -21,8 +23,8 @@ import {Comment} from './Comment.ts'; import {DocumentFragment} from './DocumentFragment.ts'; import {HTMLTemplateElement} from './HTMLTemplateElement.ts'; import { - isParentNode, cloneNode, + setOwnerDocument, getElementById as findElementById, getElementsByTagName as findElementsByTagName, } from './shared.ts'; @@ -95,10 +97,17 @@ export class Document extends ParentNode { } adoptNode(node: Node) { - if (node[OWNER_DOCUMENT] === this) return node; + if (node.nodeType === NODE_TYPE_ATTRIBUTE) { + const attr = node as Attr; + attr.ownerElement?.attributes.removeNamedItemNS( + attr.namespaceURI, + attr.name, + ); + } else { + node.parentNode?.removeChild(node); + } - node.parentNode?.removeChild(node); - adoptNode(node, this); + if (node[OWNER_DOCUMENT] !== this) setOwnerDocument(node, this); return node; } @@ -152,13 +161,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/NamedNodeMap.ts b/packages/polyfill/source/NamedNodeMap.ts index a2b5baca..0ad196f9 100644 --- a/packages/polyfill/source/NamedNodeMap.ts +++ b/packages/polyfill/source/NamedNodeMap.ts @@ -1,6 +1,7 @@ import { CHILD, OWNER_ELEMENT, + OWNER_DOCUMENT, NS, NEXT, type NamespaceURI, @@ -12,8 +13,11 @@ import { attributeObserversActive, queueMutationRecord, } from './MutationObserver.ts'; +import {toPropertyIndex} from './shared.ts'; export class NamedNodeMap { + readonly [index: number]: Attr; + [CHILD]: Attr | null = null; [OWNER_ELEMENT]: Element; @@ -22,7 +26,12 @@ export class NamedNodeMap { } getNamedItem(name: string) { - return this.getNamedItemNS(null, name); + let attr = this[CHILD]; + while (attr) { + if (attr.name === name) return attr; + attr = attr[NEXT]; + } + return null; } getNamedItemNS(namespaceURI: NamespaceURI | null, name: string) { @@ -57,48 +66,24 @@ export class NamedNodeMap { } removeNamedItem(name: string) { - return this.removeNamedItemNS(null, name); + return removeNamedAttribute(this, name, false, null); } removeNamedItemNS(namespaceURI: NamespaceURI | null, name: string) { - const ownerElement = this[OWNER_ELEMENT]; - let attr = this[CHILD]; - let prev: typeof attr | null = null; - - while (attr != null) { - if (attr.name === name && attr[NS] == namespaceURI) { - if (prev) prev[NEXT] = attr[NEXT]; - if (this[CHILD] === attr) this[CHILD] = attr[NEXT]; - if (attributeObserversActive) { - queueMutationRecord({ - type: 'attributes', - target: ownerElement, - attributeName: attr.name, - attributeNamespace: attr[NS], - oldValue: attr.value, - }); - } - updateElementAttribute(ownerElement, attr.name, attr.value, null); - ownerElement[HOOKS].removeAttribute?.( - ownerElement as any, - name, - namespaceURI, - ); - return attr; - } - - prev = attr; - attr = attr[NEXT]; - } - - return null; + return removeNamedAttribute(this, name, true, namespaceURI); } setNamedItem(attr: Attr) { const ownerElement = this[OWNER_ELEMENT]; + const currentOwner = attr[OWNER_ELEMENT]; + if (currentOwner && currentOwner !== ownerElement) { + throw new Error('The attribute is already in use by another element.'); + } + let old = null; let child = this[CHILD]; attr[OWNER_ELEMENT] = ownerElement; + attr[OWNER_DOCUMENT] = ownerElement.ownerDocument; if (child == null) { this[CHILD] = attr; // return null; @@ -106,11 +91,14 @@ export class NamedNodeMap { let prev; while (child) { if (child.name === attr.name && child[NS] == attr[NS]) { - if (prev) prev[NEXT] = attr; - else this[CHILD] = attr; - attr[NEXT] = child[NEXT]; - child[NEXT] = null; old = child; + if (child !== attr) { + if (prev) prev[NEXT] = attr; + else this[CHILD] = attr; + attr[NEXT] = child[NEXT]; + child[NEXT] = null; + child[OWNER_ELEMENT] = null; + } break; // return child; } @@ -164,6 +152,77 @@ export class NamedNodeMap { } } +// This provides ordinary indexed and named reads without proxying every map. +// Properties placed directly on a map or earlier in its prototype chain retain +// normal JavaScript precedence. Alternate Reflect receivers and full Web IDL +// reflection cannot be modeled by a shared prototype fallback. +const namedNodeMapPropertyFallback = new Proxy( + {}, + { + get(target, property, receiver) { + const namedNodeMap = receiver as NamedNodeMap; + const index = toPropertyIndex(property); + + if (index !== undefined) { + const indexedAttribute = namedNodeMap.item(index); + if (indexedAttribute) return indexedAttribute; + } + + if (property in target) { + return Reflect.get(target, property, receiver); + } + + return typeof property === 'string' + ? (namedNodeMap.getNamedItem(property) ?? undefined) + : undefined; + }, + }, +); + +Object.setPrototypeOf(NamedNodeMap.prototype, namedNodeMapPropertyFallback); + +function removeNamedAttribute( + attributes: NamedNodeMap, + name: string, + matchNamespace: boolean, + namespaceURI: NamespaceURI | null, +) { + const ownerElement = attributes[OWNER_ELEMENT]; + let attr = attributes[CHILD]; + let prev: Attr | null = null; + + while (attr) { + if (attr.name === name && (!matchNamespace || attr[NS] == namespaceURI)) { + if (prev) prev[NEXT] = attr[NEXT]; + else attributes[CHILD] = attr[NEXT]; + + if (attributeObserversActive) { + queueMutationRecord({ + type: 'attributes', + target: ownerElement, + attributeName: attr.name, + attributeNamespace: attr[NS], + oldValue: attr.value, + }); + } + attr[NEXT] = null; + attr[OWNER_ELEMENT] = null; + updateElementAttribute(ownerElement, attr.name, attr.value, null); + ownerElement[HOOKS].removeAttribute?.( + ownerElement as any, + attr.name, + attr[NS], + ); + return attr; + } + + prev = attr; + attr = attr[NEXT]; + } + + return null; +} + function updateElementAttribute( element: Element, name: string, diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index a162cbd7..48ca8444 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -13,7 +13,7 @@ import type {Node} from './Node.ts'; import {ChildNode, toNode} from './ChildNode.ts'; import {NodeList} from './NodeList.ts'; import {querySelectorAll, querySelector} from './selectors.ts'; -import {selfAndDescendants} from './shared.ts'; +import {selfAndDescendants, setOwnerDocument} from './shared.ts'; import { childListObserversActive, mutationNodeList, @@ -163,7 +163,9 @@ export class ParentNode extends ChildNode { const isElement = child.nodeType === NODE_TYPE_ELEMENT; child[PARENT] = this; - child[OWNER_DOCUMENT] = ownerDocument; + if (child[OWNER_DOCUMENT] !== ownerDocument) { + setOwnerDocument(child, ownerDocument); + } const childNodes = this.childNodes; let insertIndex: number; diff --git a/packages/polyfill/source/shared.ts b/packages/polyfill/source/shared.ts index 6a334119..739bb335 100644 --- a/packages/polyfill/source/shared.ts +++ b/packages/polyfill/source/shared.ts @@ -25,6 +25,23 @@ import { querySelectorAll, } from './selectors.ts'; +export function toPropertyIndex(property: PropertyKey) { + if (typeof property !== 'string') return undefined; + + const index = Number(property); + + // Web IDL indexed properties use canonical ECMAScript array-index names: + // whole numbers from 0 through 2^32 - 2, without aliases like "01" or "1e0". + // These inexpensive numeric guards short-circuit before string coercion and + // linked-list item lookup, both measurably slower for non-index properties. + return Number.isInteger(index) && // Reject NaN, infinities, and fractions. + index >= 0 && // Reject negative integers. + index < 2 ** 32 - 1 && // Reject integers outside the array-index range. + String(index) === property // Reject non-canonical aliases like "01" and "1e0". + ? index + : undefined; +} + export function isCharacterData(node: Node): node is CharacterData { return DATA in node; } @@ -49,6 +66,19 @@ export function isParentNode(node: Node): node is ParentNode { return 'appendChild' in node; } +export function setOwnerDocument(node: Node, document: Document) { + for (const current of selfAndDescendants(node)) { + current[OWNER_DOCUMENT] = document; + + if (isElementNode(current)) { + const attributes = current[ATTRIBUTES]; + if (attributes) { + for (const attr of attributes) attr[OWNER_DOCUMENT] = document; + } + } + } +} + export function cloneNode( node: Node, deep?: boolean, diff --git a/packages/polyfill/source/tests/named-node-map.test.ts b/packages/polyfill/source/tests/named-node-map.test.ts new file mode 100644 index 00000000..1b6ad1df --- /dev/null +++ b/packages/polyfill/source/tests/named-node-map.test.ts @@ -0,0 +1,318 @@ +import {HOOKS, SVG_NAMESPACE} from '../constants.ts'; +import {Window} from '../index.ts'; +import {NamedNodeMap} from '../NamedNodeMap.ts'; +import {toPropertyIndex} from '../shared.ts'; + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +let window: Window; + +beforeEach(() => { + window = new Window(); + Window.setGlobalThis(window); +}); + +describe('NamedNodeMap property access', () => { + it('preserves the NamedNodeMap and Object prototype chains', () => { + const attributes = document.createElement('div').attributes; + + expect(attributes).toBeInstanceOf(NamedNodeMap); + expect(Object.getPrototypeOf(attributes)).toBe(NamedNodeMap.prototype); + expect(attributes).toBeInstanceOf(Object); + expect(Object.prototype.isPrototypeOf(attributes)).toBe(true); + }); + + it('exposes attributes by index', () => { + const element = document.createElement('div'); + element.setAttribute('id', 'target'); + element.setAttribute('title', 'Target'); + + expect(element.attributes[0]).toBe(element.attributes.item(0)); + expect(element.attributes[0]?.name).toBe('id'); + expect(element.attributes[1]).toBe(element.attributes.item(1)); + expect(element.attributes[1]?.name).toBe('title'); + expect(element.attributes[2]).toBeUndefined(); + }); + + it('prioritizes indexed attributes over Object prototype properties', () => { + const element = document.createElement('div'); + element.setAttribute('id', 'target'); + const inheritedDescriptor = Object.getOwnPropertyDescriptor( + Object.prototype, + '0', + ); + + Object.defineProperty(Object.prototype, '0', { + configurable: true, + value: 'inherited', + }); + + try { + expect(element.attributes[0]).toBe(element.attributes.item(0)); + } finally { + if (inheritedDescriptor) { + Object.defineProperty(Object.prototype, '0', inheritedDescriptor); + } else { + delete (Object.prototype as any)[0]; + } + } + }); + + it('only recognizes canonical ECMAScript array indices', () => { + expect(toPropertyIndex('0')).toBe(0); + expect(toPropertyIndex('4294967294')).toBe(4294967294); + + for (const property of [ + '', + '-1', + '1.5', + '01', + '1e0', + '4294967295', + 'Infinity', + 'NaN', + ]) { + expect(toPropertyIndex(property)).toBeUndefined(); + } + expect(toPropertyIndex(Symbol.iterator)).toBeUndefined(); + }); + + it('keeps indexed access live as attributes change', () => { + const element = document.createElement('div'); + element.setAttribute('id', 'target'); + element.setAttribute('title', 'Target'); + + element.removeAttribute('id'); + + expect(element.attributes[0]?.name).toBe('title'); + expect(element.attributes[1]).toBeUndefined(); + }); + + it('exposes attributes by name without shadowing prototype properties', () => { + const element = document.createElement('div'); + element.setAttribute('id', 'target'); + element.setAttribute('item', 'attribute named item'); + element.setAttribute('toString', 'attribute named toString'); + element.setAttributeNS(SVG_NAMESPACE, 'namespaced', 'value'); + + expect((element.attributes as any).id).toBe( + element.attributes.getNamedItem('id'), + ); + expect((element.attributes as any).namespaced).toBe( + element.attributes.getNamedItemNS(SVG_NAMESPACE, 'namespaced'), + ); + expect((element.attributes as any).missing).toBeUndefined(); + expect(element.attributes.item).toBe(NamedNodeMap.prototype.item); + expect(element.attributes.toString).toBe(Object.prototype.toString); + expect(element.attributes.getNamedItem('item')?.value).toBe( + 'attribute named item', + ); + expect(element.attributes.getNamedItem('toString')?.value).toBe( + 'attribute named toString', + ); + }); + + it('keeps named access and collection identity live', () => { + const element = document.createElement('div'); + const attributes = element.attributes; + + expect(element.attributes).toBe(attributes); + expect((attributes as any).status).toBeUndefined(); + + element.setAttribute('status', 'ready'); + expect((attributes as any).status).toBe(attributes.getNamedItem('status')); + + element.removeAttribute('status'); + expect((attributes as any).status).toBeUndefined(); + }); + + it('prioritizes inherited properties over named attributes', () => { + const element = document.createElement('div'); + const property = 'namedNodeMapInheritedProperty'; + element.setAttribute(property, 'attribute'); + + Object.defineProperty(Object.prototype, property, { + configurable: true, + value: undefined, + }); + + try { + expect((element.attributes as any)[property]).toBeUndefined(); + } finally { + delete (Object.prototype as any)[property]; + } + + expect((element.attributes as any)[property]).toBe( + element.attributes.getNamedItem(property), + ); + }); + + it('removes namespaced attributes by qualified name and detaches them', () => { + const element = document.createElement('div'); + element.setAttributeNS(SVG_NAMESPACE, 'mode', 'visible'); + const attr = element.attributes[0]!; + + expect(element.attributes.removeNamedItem('mode')).toBe(attr); + expect(attr.ownerElement).toBeNull(); + expect(attr.nextSibling).toBeNull(); + expect((element.attributes as any).mode).toBeUndefined(); + }); + + it('detaches replaced attributes without disrupting the list', () => { + const element = document.createElement('div'); + element.setAttribute('id', 'first'); + element.setAttribute('title', 'title'); + const attributes = element.attributes; + const old = attributes[0]!; + + element.setAttribute('id', 'second'); + + expect(old.ownerElement).toBeNull(); + expect(old.nextSibling).toBeNull(); + expect(attributes[0]?.value).toBe('second'); + expect(attributes[1]?.name).toBe('title'); + + const setAttribute = vi.fn(); + window[HOOKS].setAttribute = setAttribute; + old.value = 'detached'; + expect(setAttribute).not.toHaveBeenCalled(); + + const current = attributes[0]!; + expect(attributes.setNamedItem(current)).toBe(current); + expect(attributes.length).toBe(2); + expect(attributes[1]?.name).toBe('title'); + }); + + it('rejects attributes owned by another element', () => { + const first = document.createElement('div'); + const second = document.createElement('div'); + first.setAttribute('id', 'first'); + const attr = first.attributes[0]!; + + expect(() => second.attributes.setNamedItem(attr)).toThrowError( + 'The attribute is already in use by another element.', + ); + expect(attr.ownerElement).toBe(first); + expect(first.getAttribute('id')).toBe('first'); + expect(second.getAttribute('id')).toBeNull(); + }); + + it('detaches an Attr when adopting it in its current document', () => { + const element = document.createElement('div'); + element.setAttribute('id', 'before'); + const attr = element.attributes[0]!; + const removeAttribute = vi.fn(); + window[HOOKS].removeAttribute = removeAttribute; + + expect(document.adoptNode(attr)).toBe(attr); + + expect(attr.ownerElement).toBeNull(); + expect(attr.ownerDocument).toBe(document); + expect(attr.nextSibling).toBeNull(); + expect(element.attributes.length).toBe(0); + expect(element.getAttribute('id')).toBeNull(); + expect(removeAttribute).toHaveBeenCalledWith(element, 'id', null); + }); + + it('detaches and transfers an Attr adopted into another document', () => { + const firstWindow = new Window(); + const secondWindow = new Window(); + const element = firstWindow.document.createElement('div'); + element.setAttributeNS(SVG_NAMESPACE, 'mode', 'before'); + const attr = element.attributes[0]!; + const firstRemoveAttribute = vi.fn(); + const secondRemoveAttribute = vi.fn(); + firstWindow[HOOKS].removeAttribute = firstRemoveAttribute; + secondWindow[HOOKS].removeAttribute = secondRemoveAttribute; + + expect(secondWindow.document.adoptNode(attr)).toBe(attr); + + expect(attr.ownerElement).toBeNull(); + expect(attr.ownerDocument).toBe(secondWindow.document); + expect(attr.nextSibling).toBeNull(); + expect(element.attributes.length).toBe(0); + expect(element.getAttributeNS(SVG_NAMESPACE, 'mode')).toBeNull(); + expect(firstRemoveAttribute).toHaveBeenCalledWith( + element, + 'mode', + SVG_NAMESPACE, + ); + expect(secondRemoveAttribute).not.toHaveBeenCalled(); + + const target = secondWindow.document.createElement('div'); + expect(target.attributes.setNamedItemNS(attr)).toBeNull(); + expect(attr.ownerElement).toBe(target); + expect(attr.ownerDocument).toBe(secondWindow.document); + expect(target.getAttributeNS(SVG_NAMESPACE, 'mode')).toBe('before'); + }); + + it('updates attribute ownership when adopting an element', () => { + const firstWindow = new Window(); + const secondWindow = new Window(); + const element = firstWindow.document.createElement('div'); + element.setAttribute('id', 'before'); + firstWindow.document.body.appendChild(element); + const attr = element.attributes[0]!; + const firstSetAttribute = vi.fn(); + const secondSetAttribute = vi.fn(); + firstWindow[HOOKS].setAttribute = firstSetAttribute; + secondWindow[HOOKS].setAttribute = secondSetAttribute; + + secondWindow.document.adoptNode(element); + attr.value = 'after'; + + expect(element.ownerDocument).toBe(secondWindow.document); + expect(attr.ownerDocument).toBe(secondWindow.document); + expect(firstSetAttribute).not.toHaveBeenCalled(); + expect(secondSetAttribute).toHaveBeenCalledWith( + element, + 'id', + 'after', + null, + ); + }); + + it('updates attribute ownership when inserting across documents', () => { + const firstWindow = new Window(); + const secondWindow = new Window(); + const element = firstWindow.document.createElement('section'); + const child = firstWindow.document.createElement('div'); + child.setAttribute('id', 'child'); + element.appendChild(child); + const attr = child.attributes[0]!; + + secondWindow.document.body.appendChild(element); + + expect(element.ownerDocument).toBe(secondWindow.document); + expect(child.ownerDocument).toBe(secondWindow.document); + expect(attr.ownerDocument).toBe(secondWindow.document); + }); + + it('supports changing an attribute through an indexed Attr', () => { + const element = document.createElement('div'); + element.setAttribute('id', 'before'); + const setAttribute = vi.fn(); + window[HOOKS].setAttribute = setAttribute; + + element.attributes[0]!.value = 'after'; + + expect(element.getAttribute('id')).toBe('after'); + expect(setAttribute).toHaveBeenCalledWith(element, 'id', 'after', null); + }); + + it('does not update an element through a detached Attr', () => { + const element = document.createElement('div'); + element.setAttribute('id', 'before'); + const attr = element.attributes[0]!; + const setAttribute = vi.fn(); + window[HOOKS].setAttribute = setAttribute; + + element.removeAttribute('id'); + attr.value = 'ghost'; + + expect(attr.ownerElement).toBeNull(); + expect(attr.ownerDocument).toBe(document); + expect(element.getAttribute('id')).toBeNull(); + expect(setAttribute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/wpt-runner/capabilities.tsv b/packages/wpt-runner/capabilities.tsv index 7d05b982..4aa4bc9b 100644 --- a/packages/wpt-runner/capabilities.tsv +++ b/packages/wpt-runner/capabilities.tsv @@ -12,7 +12,7 @@ dom/nodes/Document-getElementById.html supported Inserting an id by inserting it dom/nodes/Document-getElementById.html supported Modern browsers optimize this method with using internal id cache. This test checks that their optimization should effect only append to `Document`, not append to `Node`. dom/nodes/Document-getElementById.html deferred add id attribute via innerHTML Requires concrete `HTMLDivElement` constructor support. dom/nodes/Document-getElementById.html deferred add id attribute via outerHTML Requires an `outerHTML` setter. -dom/nodes/Document-getElementById.html deferred changing attribute's value via `Attr` gotten from `Element.attribute`. Requires indexed `NamedNodeMap` access. +dom/nodes/Document-getElementById.html supported changing attribute's value via `Attr` gotten from `Element.attribute`. dom/nodes/Document-getElementById.html supported in tree order, within the context object's tree dom/nodes/Document-getElementById.html deferred on static page Requires concrete `HTMLDivElement` constructor support. dom/nodes/Document-getElementById.html supported remove id attribute via innerHTML @@ -57,3 +57,11 @@ dom/nodes/Element-getElementsByTagName.html deferred Shouldn't be able to set un dom/nodes/Element-getElementsByTagName.html deferred getElementsByTagName('*') Requires `Node.ELEMENT_NODE` constant support. dom/nodes/Element-getElementsByTagName.html deferred getElementsByTagName() should be a live collection Requires a live `HTMLCollection`. dom/nodes/Element-getElementsByTagName.html deferred hasOwnProperty, getOwnPropertyDescriptor, getOwnPropertyNames Requires `HTMLCollection` named property semantics. +dom/nodes/attributes-namednodemap.html supported an attribute set by setAttribute should be accessible as a field on the `attributes` field of an Element +dom/nodes/attributes-namednodemap.html supported an attribute with a null namespace should be accessible as a field on the `attributes` field of an Element +dom/nodes/attributes-namednodemap.html supported an attribute with a set namespace should be accessible as a field on the `attributes` field of an Element +dom/nodes/attributes-namednodemap.html deferred setNamedItem and removeNamedItem on `attributes` should add and remove fields from `attributes` Requires `Document.createAttribute()`. +dom/nodes/attributes-namednodemap.html deferred setNamedItem and removeNamedItem on `attributes` should not interfere with existing method names Requires `Document.createAttribute()` and an exposed `NamedNodeMap` constructor. +dom/nodes/attributes-namednodemap.html supported setting an attribute should not overwrite the length property of an `NamedNodeMap` object +dom/nodes/attributes-namednodemap.html supported setting an attribute should not overwrite the methods defined by prototype ancestors of an `NamedNodeMap` object +dom/nodes/attributes-namednodemap.html supported setting an attribute should not overwrite the methods of an `NamedNodeMap` object