Skip to content
5 changes: 5 additions & 0 deletions .changeset/named-node-map-properties.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 13 additions & 14 deletions packages/polyfill/source/Document.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
NS,
NAME,
NODE_TYPE_ATTRIBUTE,
NODE_TYPE_DOCUMENT,
SVG_NAMESPACE,
type NamespaceURI,
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -152,13 +161,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);
}
}
}
133 changes: 96 additions & 37 deletions packages/polyfill/source/NamedNodeMap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
CHILD,
OWNER_ELEMENT,
OWNER_DOCUMENT,
NS,
NEXT,
type NamespaceURI,
Expand All @@ -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;

Expand All @@ -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) {
Expand Down Expand Up @@ -57,60 +66,39 @@ 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;
} else {
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;
}
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions packages/polyfill/source/ParentNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
30 changes: 30 additions & 0 deletions packages/polyfill/source/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down
Loading
Loading