Skip to content
Open
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/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`.
32 changes: 32 additions & 0 deletions packages/polyfill/source/NamedNodeMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ import {
} from './MutationObserver.ts';
import {performWithCustomElementReactions} from './custom-element-reactions.ts';
import {enqueueAttributeReaction} from './attribute-reactions.ts';
import {toPropertyIndex} from './shared.ts';

export class NamedNodeMap {
readonly [index: number]: Attr;

[CHILD]: Attr | null = null;
[OWNER_ELEMENT]: Element;

Expand Down Expand Up @@ -246,3 +249,32 @@ 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);
17 changes: 17 additions & 0 deletions packages/polyfill/source/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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) &&
index >= 0 &&
index < 2 ** 32 - 1 &&
String(index) === property
? index
: undefined;
}

export function createNotSupportedError(message: string) {
if (typeof DOMException === 'function') {
return new DOMException(message, 'NotSupportedError');
Expand Down
213 changes: 210 additions & 3 deletions packages/polyfill/source/tests/named-node-map.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {beforeEach, describe, expect, it, vi} from 'vitest';

import {Attr} from '../Attr.ts';
import {NEXT} from '../constants.ts';
import {HOOKS, NEXT} from '../constants.ts';
import {Window} from '../index.ts';
import {NamedNodeMap} from '../NamedNodeMap.ts';
import {toPropertyIndex} from '../shared.ts';

let window: Window;
let document: Window['document'];

beforeEach(() => {
const window = new Window();
window = new Window();
Window.setGlobalThis(window);
document = window.document;
});
Expand Down Expand Up @@ -119,3 +122,207 @@ describe('NamedNodeMap invariants', () => {
expect(attribute.ownerElement).toBe(secondElement);
});
});

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 live 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[1]).toBe(element.attributes.item(1));
expect(element.attributes[2]).toBeUndefined();

element.removeAttribute('id');

expect(element.attributes[0]?.name).toBe('title');
expect(element.attributes[1]).toBeUndefined();
});

it('prioritizes indexed attributes over numeric 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',
writable: true,
});

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',
'-Infinity',
'NaN',
]) {
expect(toPropertyIndex(property)).toBeUndefined();
}
expect(toPropertyIndex(Symbol.iterator)).toBeUndefined();
});

it.each(['01', '1e0', '-1', '1.5', '4294967295'])(
'delegates rejected numeric-looking property %s to its original name',
(property) => {
const attributes = document.createElement('div').attributes;
const item = vi.spyOn(attributes, 'item');
const getNamedItem = vi.spyOn(attributes, 'getNamedItem');

expect((attributes as any)[property]).toBeUndefined();
expect(item).not.toHaveBeenCalled();
expect(getNamedItem).toHaveBeenCalledOnce();
expect(getNamedItem).toHaveBeenCalledWith(property);
},
);

it('lets own expandos mask indexed and named attributes until deleted', () => {
const element = document.createElement('div');
element.setAttribute('status', 'ready');
const attributes = element.attributes;
const attribute = attributes.item(0)!;

Object.defineProperties(attributes, {
0: {configurable: true, value: 'numeric expando'},
status: {configurable: true, value: 'named expando'},
});

expect((attributes as any)[0]).toBe('numeric expando');
expect((attributes as any).status).toBe('named expando');

delete (attributes as any)[0];
delete (attributes as any).status;

expect(attributes[0]).toBe(attribute);
expect((attributes as any).status).toBe(attribute);
});

it('exposes attributes by qualified name without shadowing prototypes', () => {
const element = document.createElement('div');
element.setAttribute('id', 'target');
element.setAttribute('item', 'attribute named item');
element.setAttribute('toString', 'attribute named toString');
element.setAttributeNS('urn:state', 'state:mode', 'ready');

expect((element.attributes as any).id).toBe(
element.attributes.getNamedItem('id'),
);
expect((element.attributes as any)['state:mode']).toBe(
element.attributes.getNamedItem('state:mode'),
);
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('routes mutation through an indexed Attr to its owning element hook', () => {
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('routes reattached indexed Attr mutation only to the destination hook', () => {
const sourceWindow = new Window();
const destinationWindow = new Window();
const source = sourceWindow.document.createElement('div');
const destination = destinationWindow.document.createElement('div');
source.setAttributeNS('urn:state', 'state:mode', 'before');
const attribute = source.attributes.removeNamedItemNS('urn:state', 'mode');

expect(attribute).not.toBeNull();
destination.attributes.setNamedItemNS(attribute!);

const sourceSetAttribute = vi.fn();
const destinationSetAttribute = vi.fn();
sourceWindow[HOOKS].setAttribute = sourceSetAttribute;
destinationWindow[HOOKS].setAttribute = destinationSetAttribute;

destination.attributes[0]!.value = 'after';

expect(sourceSetAttribute).not.toHaveBeenCalled();
expect(destinationSetAttribute).toHaveBeenCalledWith(
destination,
'state:mode',
'after',
'urn:state',
);
});
});
10 changes: 9 additions & 1 deletion packages/wpt-runner/capabilities.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +57,14 @@ 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
dom/nodes/getElementsByClassName-19.htm supported get elements in document
dom/nodes/getElementsByClassName-23.htm supported multiple defined classes
dom/nodes/getElementsByClassName-24.htm supported handle unicode chars
Expand Down
Loading