From 6efd10395fc626ab41d3860f1114d1d8b19796f5 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Mon, 31 Aug 2026 19:56:59 -0700 Subject: [PATCH 01/10] Add NamedNodeMap property access Baseline ESM bundle (esbuild --bundle --minify): 20,773 bytes minified, 7,029 bytes gzip, and 6,308 bytes brotli. --- packages/polyfill/source/NamedNodeMap.ts | 42 ++++++++++- .../source/tests/named-node-map.test.ts | 70 +++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 packages/polyfill/source/tests/named-node-map.test.ts diff --git a/packages/polyfill/source/NamedNodeMap.ts b/packages/polyfill/source/NamedNodeMap.ts index a2b5baca..ea2df3cf 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, @@ -14,15 +15,24 @@ import { } from './MutationObserver.ts'; export class NamedNodeMap { + readonly [index: number]: Attr; + [CHILD]: Attr | null = null; [OWNER_ELEMENT]: Element; constructor(ownerElement: Element) { this[OWNER_ELEMENT] = ownerElement; + + return new Proxy(this, namedNodeMapProxyHandler); } 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) { @@ -99,6 +109,7 @@ export class NamedNodeMap { let old = null; let child = this[CHILD]; attr[OWNER_ELEMENT] = ownerElement; + attr[OWNER_DOCUMENT] = ownerElement.ownerDocument; if (child == null) { this[CHILD] = attr; // return null; @@ -164,6 +175,35 @@ export class NamedNodeMap { } } +const namedNodeMapProxyHandler: ProxyHandler = { + get(target, property, receiver) { + const index = toPropertyIndex(property); + const indexedAttribute = index == null ? null : target.item(index); + + if (indexedAttribute) return indexedAttribute; + + if (Reflect.has(target, property)) { + return Reflect.get(target, property, receiver); + } + + if (typeof property === 'string') { + return target.getNamedItem(property) ?? undefined; + } + + return undefined; + }, +}; + +function toPropertyIndex(property: PropertyKey) { + if (typeof property !== 'string') return undefined; + + const index = Number(property); + + return Number.isSafeInteger(index) && index >= 0 && String(index) === property + ? index + : undefined; +} + function updateElementAttribute( element: Element, name: string, 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..6e7cb769 --- /dev/null +++ b/packages/polyfill/source/tests/named-node-map.test.ts @@ -0,0 +1,70 @@ +import {SVG_NAMESPACE} from '../constants.ts'; +import {Window} from '../index.ts'; +import {NamedNodeMap} from '../NamedNodeMap.ts'; + +import {beforeEach, describe, expect, it} from 'vitest'; + +beforeEach(() => { + const window = new Window(); + Window.setGlobalThis(window); +}); + +describe('NamedNodeMap property access', () => { + it('returns a proxy that preserves the NamedNodeMap prototype', () => { + const attributes = document.createElement('div').attributes; + + expect(attributes).toBeInstanceOf(NamedNodeMap); + expect(Object.getPrototypeOf(attributes)).toBe(NamedNodeMap.prototype); + }); + + 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('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.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.getNamedItem('item')?.value).toBe( + 'attribute named item', + ); + }); + + it('supports changing an attribute through an indexed Attr', () => { + const element = document.createElement('div'); + element.setAttribute('id', 'before'); + + element.attributes[0]!.value = 'after'; + + expect(element.getAttribute('id')).toBe('after'); + }); +}); From 17f777bb2926265eb1cd5cf5ad58b722a4806a04 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Mon, 31 Aug 2026 20:30:22 -0700 Subject: [PATCH 02/10] Simplify NamedNodeMap indexed lookup ESM bundle: 20,682 bytes minified, 6,991 bytes gzip, and 6,281 bytes brotli. Saves 91 minified bytes, 38 gzip bytes, and 27 brotli bytes versus 61b4bba. --- packages/polyfill/source/NamedNodeMap.ts | 30 ++++++++---------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/polyfill/source/NamedNodeMap.ts b/packages/polyfill/source/NamedNodeMap.ts index ea2df3cf..fdcc38ce 100644 --- a/packages/polyfill/source/NamedNodeMap.ts +++ b/packages/polyfill/source/NamedNodeMap.ts @@ -177,33 +177,23 @@ export class NamedNodeMap { const namedNodeMapProxyHandler: ProxyHandler = { get(target, property, receiver) { - const index = toPropertyIndex(property); - const indexedAttribute = index == null ? null : target.item(index); - - if (indexedAttribute) return indexedAttribute; + if (typeof property === 'string') { + const index = Number(property); - if (Reflect.has(target, property)) { - return Reflect.get(target, property, receiver); - } + if (index >= 0 && index % 1 === 0 && String(index) === property) { + const indexedAttribute = target.item(index); + if (indexedAttribute) return indexedAttribute; + } - if (typeof property === 'string') { - return target.getNamedItem(property) ?? undefined; + if (!Reflect.has(target, property)) { + return target.getNamedItem(property) ?? undefined; + } } - return undefined; + return Reflect.get(target, property, receiver); }, }; -function toPropertyIndex(property: PropertyKey) { - if (typeof property !== 'string') return undefined; - - const index = Number(property); - - return Number.isSafeInteger(index) && index >= 0 && String(index) === property - ? index - : undefined; -} - function updateElementAttribute( element: Element, name: string, From 4504bbc632106ece7cf7f2018d5227849952f80c Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Tue, 1 Sep 2026 17:18:11 -0700 Subject: [PATCH 03/10] Use a shared NamedNodeMap property fallback Keep NamedNodeMap instances on their ordinary prototype path while resolving indexed and named properties through one shared proxy. Preserve indexed precedence and inherited-property masking. ESM bundle (esbuild --bundle --minify): 20,707 bytes minified, 7,012 bytes gzip, and 6,309 bytes brotli. This is 25 minified bytes, 21 gzip bytes, and 28 brotli bytes larger than the per-instance proxy in 6d2a7b3. Runtime benchmark (Node 24.19.0, Apple M4 Pro, three processes with 15 warmed samples each): getAttribute throughput is 11-15% above the pre-proxy dda8de3 implementation; replacement, removal, length, item, iteration, mixed access, and serialization are within about 1% of pre-proxy. Compared with 6d2a7b3, representative common operations improve by 2.2-12.5x. Creating an element, setting its first attribute, and reading map length remains about 14% below pre-proxy and 43% above 6d2a7b3. Direct indexed and named reads measure 12.5M and 20.6M operations/second respectively. Retained V8 heap usage returns to the pre-proxy baseline: approximately 344 bytes for an element with an empty materialized map and 882 bytes with four attributes, versus 376 and 914 bytes with the per-instance proxy. This removes about 32 bytes per materialized NamedNodeMap. Index validation benchmark: retaining the non-negative whole-number checks costs 15 minified bytes, 13 gzip bytes, and 11 brotli bytes versus a parsed canonical-number-only helper, or 36 minified bytes, 19 gzip bytes, and 15 brotli bytes versus the PR #619-style check. The stricter check matches or slightly exceeds valid-index throughput, improves ordinary named and missing access by 5-7%, and improves numeric-looking named access by 22-42% by avoiding unnecessary item() traversal. It has no retained-memory cost. --- packages/polyfill/source/NamedNodeMap.ts | 36 +++++++------ .../source/tests/named-node-map.test.ts | 54 ++++++++++++++++++- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/packages/polyfill/source/NamedNodeMap.ts b/packages/polyfill/source/NamedNodeMap.ts index fdcc38ce..3b1f69db 100644 --- a/packages/polyfill/source/NamedNodeMap.ts +++ b/packages/polyfill/source/NamedNodeMap.ts @@ -22,8 +22,6 @@ export class NamedNodeMap { constructor(ownerElement: Element) { this[OWNER_ELEMENT] = ownerElement; - - return new Proxy(this, namedNodeMapProxyHandler); } getNamedItem(name: string) { @@ -175,24 +173,30 @@ export class NamedNodeMap { } } -const namedNodeMapProxyHandler: ProxyHandler = { - get(target, property, receiver) { - if (typeof property === 'string') { - const index = Number(property); - - if (index >= 0 && index % 1 === 0 && String(index) === property) { - const indexedAttribute = target.item(index); - if (indexedAttribute) return indexedAttribute; - } +const namedNodeMapPropertyFallback = new Proxy( + {}, + { + get(target, property, receiver) { + if (typeof property === 'string') { + const namedNodeMap = receiver as NamedNodeMap; + const index = Number(property); + + if (index >= 0 && index % 1 === 0 && String(index) === property) { + const indexedAttribute = namedNodeMap.item(index); + if (indexedAttribute) return indexedAttribute; + } - if (!Reflect.has(target, property)) { - return target.getNamedItem(property) ?? undefined; + if (!(property in target)) { + return namedNodeMap.getNamedItem(property) ?? undefined; + } } - } - return Reflect.get(target, property, receiver); + return Reflect.get(target, property, receiver); + }, }, -}; +); + +Object.setPrototypeOf(NamedNodeMap.prototype, namedNodeMapPropertyFallback); function updateElementAttribute( element: Element, diff --git a/packages/polyfill/source/tests/named-node-map.test.ts b/packages/polyfill/source/tests/named-node-map.test.ts index 6e7cb769..42aa3276 100644 --- a/packages/polyfill/source/tests/named-node-map.test.ts +++ b/packages/polyfill/source/tests/named-node-map.test.ts @@ -10,11 +10,13 @@ beforeEach(() => { }); describe('NamedNodeMap property access', () => { - it('returns a proxy that preserves the NamedNodeMap prototype', () => { + 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', () => { @@ -29,6 +31,30 @@ describe('NamedNodeMap property access', () => { expect(element.attributes[2]).toBeUndefined(); }); + it('prioritizes indexed attributes over inherited numeric 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('keeps indexed access live as attributes change', () => { const element = document.createElement('div'); element.setAttribute('id', 'target'); @@ -44,6 +70,7 @@ describe('NamedNodeMap property access', () => { 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( @@ -54,9 +81,34 @@ describe('NamedNodeMap property access', () => { ); 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('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('supports changing an attribute through an indexed Attr', () => { From 2925fc88973747bd5246cf62c2a61e92b7778a4e Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Tue, 1 Sep 2026 18:12:10 -0700 Subject: [PATCH 04/10] Share property index parsing Move the strict canonical non-negative integer parser to shared.ts so NamedNodeMap and the DOMTokenList work in PR #619 can use the same property-index semantics. Keep each collection's shared-prototype proxy policy local until another implementation demonstrates a useful larger abstraction. ESM bundle (esbuild --bundle --minify): 20,780 bytes minified, 7,040 bytes gzip, and 6,332 bytes brotli. Extracting the helper adds 73 minified bytes, 28 gzip bytes, and 23 brotli bytes versus the inline implementation in 9e2a1c0. Runtime benchmark (Node 24.19.0, Apple M4 Pro, three processes with 15 warmed samples each): helper extraction changes valid-index throughput by -0.3% to -1.3%, ordinary named and missing access by -2.2% to -2.9%, and numeric-looking named access by -1.3% to -2.7%. It does not change retained object layout or memory usage. Compared with simpler index checks, the strict parser costs 15 minified bytes, 13 gzip bytes, and 11 brotli bytes versus a parsed canonical-number-only helper, or 36 minified bytes, 19 gzip bytes, and 15 brotli bytes versus the PR #619-style check. It matches or slightly exceeds valid-index throughput, improves ordinary named and missing access by 5-7%, and improves numeric-looking named access by 22-42% by avoiding unnecessary item() traversal. --- packages/polyfill/source/NamedNodeMap.ts | 23 ++++++++++++----------- packages/polyfill/source/shared.ts | 10 ++++++++++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/polyfill/source/NamedNodeMap.ts b/packages/polyfill/source/NamedNodeMap.ts index 3b1f69db..2bb3d5b7 100644 --- a/packages/polyfill/source/NamedNodeMap.ts +++ b/packages/polyfill/source/NamedNodeMap.ts @@ -13,6 +13,7 @@ import { attributeObserversActive, queueMutationRecord, } from './MutationObserver.ts'; +import {toPropertyIndex} from './shared.ts'; export class NamedNodeMap { readonly [index: number]: Attr; @@ -177,21 +178,21 @@ const namedNodeMapPropertyFallback = new Proxy( {}, { get(target, property, receiver) { - if (typeof property === 'string') { - const namedNodeMap = receiver as NamedNodeMap; - const index = Number(property); + const namedNodeMap = receiver as NamedNodeMap; + const index = toPropertyIndex(property); - if (index >= 0 && index % 1 === 0 && String(index) === property) { - const indexedAttribute = namedNodeMap.item(index); - if (indexedAttribute) return indexedAttribute; - } + if (index !== undefined) { + const indexedAttribute = namedNodeMap.item(index); + if (indexedAttribute) return indexedAttribute; + } - if (!(property in target)) { - return namedNodeMap.getNamedItem(property) ?? undefined; - } + if (property in target) { + return Reflect.get(target, property, receiver); } - return Reflect.get(target, property, receiver); + return typeof property === 'string' + ? (namedNodeMap.getNamedItem(property) ?? undefined) + : undefined; }, }, ); diff --git a/packages/polyfill/source/shared.ts b/packages/polyfill/source/shared.ts index 6a334119..1ca0ceb2 100644 --- a/packages/polyfill/source/shared.ts +++ b/packages/polyfill/source/shared.ts @@ -25,6 +25,16 @@ import { querySelectorAll, } from './selectors.ts'; +export function toPropertyIndex(property: PropertyKey) { + if (typeof property !== 'string') return undefined; + + const index = Number(property); + + return index >= 0 && index % 1 === 0 && String(index) === property + ? index + : undefined; +} + export function isCharacterData(node: Node): node is CharacterData { return DATA in node; } From b024f53320db6f74b5a03ac9ed1bc4f37a82aea1 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Tue, 1 Sep 2026 23:05:23 -0700 Subject: [PATCH 05/10] Classify NamedNodeMap property access WPT Enable six supported attributes-namednodemap subtests and defer the two cases that still require Document.createAttribute, with one also requiring an exposed NamedNodeMap constructor. Promote the getElementById Attr.value mutation case now that indexed NamedNodeMap access is supported. --- packages/wpt-runner/capabilities.tsv | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 From 680bcf6ec504e6979d4cb8630d5ab536c32c7f8c Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 2 Sep 2026 15:31:03 -0700 Subject: [PATCH 06/10] Tighten property index detection Limit indexed collection properties to canonical ECMAScript array indices and document why the numeric guards avoid slower coercion and linked-list traversal. Clarify the shared prototype fallback's deliberate Web IDL limitations and add boundary and liveness coverage. --- packages/polyfill/source/NamedNodeMap.ts | 4 +++ packages/polyfill/source/shared.ts | 9 ++++- .../source/tests/named-node-map.test.ts | 36 ++++++++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/polyfill/source/NamedNodeMap.ts b/packages/polyfill/source/NamedNodeMap.ts index 2bb3d5b7..345446a2 100644 --- a/packages/polyfill/source/NamedNodeMap.ts +++ b/packages/polyfill/source/NamedNodeMap.ts @@ -174,6 +174,10 @@ 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( {}, { diff --git a/packages/polyfill/source/shared.ts b/packages/polyfill/source/shared.ts index 1ca0ceb2..8e382be0 100644 --- a/packages/polyfill/source/shared.ts +++ b/packages/polyfill/source/shared.ts @@ -30,7 +30,14 @@ export function toPropertyIndex(property: PropertyKey) { const index = Number(property); - return index >= 0 && index % 1 === 0 && String(index) === 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; } diff --git a/packages/polyfill/source/tests/named-node-map.test.ts b/packages/polyfill/source/tests/named-node-map.test.ts index 42aa3276..b5977a3f 100644 --- a/packages/polyfill/source/tests/named-node-map.test.ts +++ b/packages/polyfill/source/tests/named-node-map.test.ts @@ -1,6 +1,7 @@ import {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} from 'vitest'; @@ -31,7 +32,7 @@ describe('NamedNodeMap property access', () => { expect(element.attributes[2]).toBeUndefined(); }); - it('prioritizes indexed attributes over inherited numeric properties', () => { + it('prioritizes indexed attributes over Object prototype properties', () => { const element = document.createElement('div'); element.setAttribute('id', 'target'); const inheritedDescriptor = Object.getOwnPropertyDescriptor( @@ -55,6 +56,25 @@ describe('NamedNodeMap property access', () => { } }); + 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'); @@ -90,6 +110,20 @@ describe('NamedNodeMap property access', () => { ); }); + 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'; From d9621c8d88d74e2e19fc9933c39367e7a2723fb5 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 2 Sep 2026 15:31:34 -0700 Subject: [PATCH 07/10] Maintain attribute ownership during map mutations Remove qualified-name matches regardless of namespace, detach removed and replaced Attr nodes, preserve list links when setting an Attr already in the map, and reject attributes owned by another element. Add hook and lifecycle regression coverage. --- packages/polyfill/source/NamedNodeMap.ts | 92 +++++++++++-------- .../source/tests/named-node-map.test.ts | 77 +++++++++++++++- 2 files changed, 130 insertions(+), 39 deletions(-) diff --git a/packages/polyfill/source/NamedNodeMap.ts b/packages/polyfill/source/NamedNodeMap.ts index 345446a2..0ad196f9 100644 --- a/packages/polyfill/source/NamedNodeMap.ts +++ b/packages/polyfill/source/NamedNodeMap.ts @@ -66,45 +66,20 @@ 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; @@ -116,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; } @@ -203,6 +181,48 @@ const namedNodeMapPropertyFallback = new Proxy( 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/tests/named-node-map.test.ts b/packages/polyfill/source/tests/named-node-map.test.ts index b5977a3f..b6a26aea 100644 --- a/packages/polyfill/source/tests/named-node-map.test.ts +++ b/packages/polyfill/source/tests/named-node-map.test.ts @@ -1,12 +1,14 @@ -import {SVG_NAMESPACE} from '../constants.ts'; +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} from 'vitest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +let window: Window; beforeEach(() => { - const window = new Window(); + window = new Window(); Window.setGlobalThis(window); }); @@ -145,12 +147,81 @@ describe('NamedNodeMap property access', () => { ); }); + 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('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(); }); }); From 30c7aa3c2debac7709636573a09cf1a6b68bbfda Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 2 Sep 2026 15:31:45 -0700 Subject: [PATCH 08/10] Propagate owner documents across node adoption Update descendant nodes and attached attributes when adopting or inserting a subtree into another document, preserving Attr.ownerDocument and routing later attribute hooks through the new window. Add explicit adoption and cross-document insertion coverage. --- packages/polyfill/source/Document.ts | 14 +------ packages/polyfill/source/ParentNode.ts | 6 ++- packages/polyfill/source/shared.ts | 13 ++++++ .../source/tests/named-node-map.test.ts | 42 +++++++++++++++++++ 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/packages/polyfill/source/Document.ts b/packages/polyfill/source/Document.ts index 131aeb57..19cc6cdd 100644 --- a/packages/polyfill/source/Document.ts +++ b/packages/polyfill/source/Document.ts @@ -21,8 +21,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'; @@ -98,7 +98,7 @@ export class Document extends ParentNode { if (node[OWNER_DOCUMENT] === this) return node; node.parentNode?.removeChild(node); - adoptNode(node, this); + setOwnerDocument(node, this); return node; } @@ -152,13 +152,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/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 8e382be0..739bb335 100644 --- a/packages/polyfill/source/shared.ts +++ b/packages/polyfill/source/shared.ts @@ -66,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 index b6a26aea..6e7de41a 100644 --- a/packages/polyfill/source/tests/named-node-map.test.ts +++ b/packages/polyfill/source/tests/named-node-map.test.ts @@ -197,6 +197,48 @@ describe('NamedNodeMap property access', () => { expect(second.getAttribute('id')).toBeNull(); }); + 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'); From bbecd981ce044203084983f1a79992589b2749be Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 2 Sep 2026 15:31:57 -0700 Subject: [PATCH 09/10] Add NamedNodeMap property access changeset --- .changeset/named-node-map-properties.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/named-node-map-properties.md 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. From 6c2008f64770b27c209937420fd42f6f83bb5d6c Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 2 Sep 2026 17:51:15 -0700 Subject: [PATCH 10/10] Detach adopted attributes from their elements --- packages/polyfill/source/Document.ts | 17 +++++-- .../source/tests/named-node-map.test.ts | 49 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/polyfill/source/Document.ts b/packages/polyfill/source/Document.ts index 19cc6cdd..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'; @@ -95,10 +97,17 @@ export class Document extends ParentNode { } adoptNode(node: Node) { - if (node[OWNER_DOCUMENT] === this) return node; - - node.parentNode?.removeChild(node); - setOwnerDocument(node, this); + if (node.nodeType === NODE_TYPE_ATTRIBUTE) { + const attr = node as Attr; + attr.ownerElement?.attributes.removeNamedItemNS( + attr.namespaceURI, + attr.name, + ); + } else { + node.parentNode?.removeChild(node); + } + + if (node[OWNER_DOCUMENT] !== this) setOwnerDocument(node, this); return node; } diff --git a/packages/polyfill/source/tests/named-node-map.test.ts b/packages/polyfill/source/tests/named-node-map.test.ts index 6e7de41a..1b6ad1df 100644 --- a/packages/polyfill/source/tests/named-node-map.test.ts +++ b/packages/polyfill/source/tests/named-node-map.test.ts @@ -197,6 +197,55 @@ describe('NamedNodeMap property access', () => { 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();