Skip to content

Add indexed and named NamedNodeMap access - #723

Open
andrewiggins wants to merge 3 commits into
mainfrom
named-node-map-properties-v2
Open

andrewiggins wants to merge 3 commits into
mainfrom
named-node-map-properties-v2

Conversation

@andrewiggins

@andrewiggins andrewiggins commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Add live browser-style property reads to Element.attributes without making NamedNodeMap inherit from Array or proxying each instance:

element.attributes[0];
element.attributes.id;
  • Resolve indexed and named reads through one shared proxy in the prototype chain, preserving collection identity and avoiding a per-map proxy allocation.
  • Match named attributes by qualified name across namespaces while preserving methods, inherited named properties, and ordinary expando precedence.
  • Parse only canonical ECMAScript array-index names ("0" through "4294967294"), rejecting fractions, aliases, and out-of-range values before linked-list index lookup.
  • Preserve current mutation and custom-element reaction behavior.
  • Add focused regression coverage, WPT classifications, and a minor changeset for @remote-dom/polyfill.

This is the focused property-access portion of #693, rebuilt on current main. Removal/replacement fixes and attached-Attr adoption are not included.

Design and performance

Fresh measurements on 2026-09-17 replace the historical benchmark claims from #693. The original harness/raw samples were unavailable, so these are reconstructed microbenchmarks, not an exact reproduction of that experiment.

Environment: Apple M4 Pro, arm64 macOS 26.6.2; Node 24.19.0 / V8 13.6.233.17-node.51; pnpm 10.28.0. These are Node/V8 measurements, not browser or application-level performance claims.

Compared exact base a13aa618182d5c84d1c733977d1ac66955b95dbc with PR head 2f9c14177749f30b3f18f82ad85c162c3d55a9b3, built with the same pinned toolchain. A third, scratch variant applies the rejected constructor-returned per-instance proxy design to the current head while retaining its mutation logic and strict parser; it does not compare against the older branch's unrelated code.

Runtime

Five fresh processes per variant/workload group, run serially in rotating variant order. Each process used 15 warm-up and 15 measured batches calibrated to roughly 10–15 ms, with observable checksums. Established-operation fixtures rotate through 257 elements with four attributes. Times include fixture selection/checksum overhead. Reported ratios are the median paired head/base time ratio, with the min–max across five process pairs—not confidence intervals. Lower is faster; differences around ±5% are treated cautiously, with no aggregate workload score.

Workload Head/base time Five-pair range
getAttribute hit 0.95× 0.88–1.11×
Replace existing attribute, toggling value 0.99× 0.97–0.99×
Remove + re-add attribute (paired operation) 1.02× 0.95–1.05×
attributes.length 0.97× 0.90–1.05×
item() with varying index 0.99× 0.97–1.01×
Iterate four attributes 1.01× 0.99–1.02×
outerHTML serialization 1.01× 0.97–1.03×
Create element + materialize empty map 1.10× 1.06–1.13×
Create element + set first attribute + read length 1.10× 1.07–1.13×

Most established-operation medians remain close to baseline; getAttribute was noisy and is not claimed as an improvement. Both measured creation paths are about 10% slower, so the change is not performance-neutral across all operations. An additional independent head/base spot-check reproduced that creation regression.

Compared with the current shared fallback, the scratch per-instance proxy took 1.78–8.15× as long for the tested get/set/remove/length/item/iteration workloads, 1.08× for serialization, and 1.37× for creation + first attribute. It was not slower everywhere: indexed property reads were about 9% faster, while named hits were about 11% slower. This supports choosing the shared fallback for existing method paths rather than claiming universal superiority.

Heap

Measured post-GC process.memoryUsage().heapUsed slopes over 0, 25,000, 50,000, and 100,000 retained elements, each with a materialized map. Five fresh processes per variant/shape; five full GCs before each reading; equal preallocated reference arrays and one separately retained Window. These are fitted used-heap bytes per element + map (+ attributes), not exact object retained sizes or RSS; a fixed shared-proxy allocation is not a per-map cost.

Shape Base Shared fallback Per-instance proxy
Empty materialized map ~352 B ~352 B ~384 B
Four attributes ~992 B ~992 B ~1,024 B

No per-map slope increase was measured for the shared fallback versus base. The per-instance proxy adds ~32 B/map in both shapes, reproducing the earlier heap finding on the current code.

Index parser

A separate five-round property-read comparison tested the current parser against three semantically equivalent variants: bounds-first checks, unsigned-bitwise validation, and canonical-string equality before integer/bounds checks. Actual built parser exports passed accepted/rejected input checks, including symbols, fractions, aliases, numeric inputs, and array-index boundaries.

Bounds-first and bitwise variants were within ±5% of the current parser by median across the four workloads, with overlapping/noisy ranges. String-first matched indexed reads, but took 1.04× as long for named hits, 1.06× for missing names, and 1.20× for rejected numeric-looking keys (five-pair range 1.17–1.23× for the latter). This supports rejecting invalid numeric forms before string coercion; it does not establish that the current expression is universally fastest.

Bundle size

Fresh pnpm size --json runs after isolated frozen-lockfile installs measured the repository's polyfill Brotli fixture at 10,893 B on base → 11,060 B on head: +167 B (+1.53%). Both pass the unchanged 12,500 B limit. These replace the old branch's inapplicable size delta.

Web Platform Tests

  • Enable six supported cases in dom/nodes/attributes-namednodemap.html.
  • Keep two cases deferred because they require Document.createAttribute(), with one also requiring an exposed NamedNodeMap constructor.
  • Promote the Document-getElementById.html case that mutates an ID through element.attributes[0].value.

Intentional scope

The shared prototype fallback supports ordinary indexed and named reads, not a complete Web IDL legacy-platform-object implementation. It does not add has, own-key/descriptor reflection, assignment/deletion restrictions, alternate Reflect.get() receiver behavior, or indexed precedence over properties placed directly on a map or on NamedNodeMap.prototype.

Document.createAttribute() and global NamedNodeMap exposure remain outside this PR. Existing namespace normalization, HTML case-folding, mutation invariants, and reactions on main are preserved rather than reimplemented.

Validation

  • Focused NamedNodeMap tests: 24 passed.
  • Full unit suite: 618 tests passed across 44 files.
  • pnpm type-check, pnpm lint, pnpm build, pnpm size, and git diff --check: passed.
  • Playwright: 13 tests passed on an unchanged retry; the initial run had four iframe interaction timeouts.
  • Classified WPT: 13 files passed, with all 78 supported cases passing and 23 expected deferred failures. Used the cached pinned WPT assets.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant