@miaskiewicz/turbo-dom 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ npm install -D @miaskiewicz/turbo-dom
14
14
 
15
15
  - ✅ **More compatible than happy-dom** — 99.72% on html5lib-tests vs happy-dom's 37%.
16
16
  Runs React Testing Library, `user-event`, downshift, Radix UI, and Headless UI unmodified.
17
- - ⚡ **Faster than both** — ~23× jsdom / ~10× happy-dom on per-file setup, 18–37× faster HTML parsing, and (with per-version query-result caching) it matches/beats happy-dom on repeated queries while staying 99.7% spec-correct.
17
+ - ⚡ **Faster than both** — ~120× jsdom / ~40× happy-dom on a realistic suite (parse-memoized repeated shells), 18–37× faster HTML parsing, and it beats happy-dom on repeated queries while staying 99.7% spec-correct.
18
18
  - 🎯 **Honest, not lying** — no fake layout numbers; `getBoundingClientRect()` is zeros and
19
19
  `getComputedStyle` reflects only what you set. Geometry tests belong in a real browser.
20
20
 
@@ -105,20 +105,26 @@ the suite row (ms/file, lower = faster):
105
105
 
106
106
  | benchmark | turbo-dom | happy-dom | jsdom |
107
107
  |---|---:|---:|---:|
108
- | **per-file setup + 1 query** (ops/s) | **5,900** | 600 | 250 |
109
- | **realistic suite**, 200 files (ms/file) | **0.15** | 2.1 | 4.9 |
108
+ | **realistic suite**, 200 files (ms/file) | **0.022** | 1.12 | 3.47 |
109
+ | **per-file setup** (ops/s) | **~500k** | 396 | 144 |
110
110
  | **parse 56 KB SSR** (ops/s) | **478** | 43 | 26 |
111
- | **parse 20 KB real page** (ops/s) | **4,203** | 190 | 114 |
112
- | repeated query throughput (iters/s) | **920k** | 600k | 3k |
111
+ | **parse 20 KB real page** (ops/s) | **2,800** | 600 | 290 |
112
+ | repeated query throughput (iters/s) | **994k** | 692k | 3k |
113
113
  | html5lib conformance | **99.72%** | 37.35% | 97.03% |
114
114
 
115
- Roughly **~25–30× jsdom** and **~10–14× happy-dom** on per-file setup / realistic
116
- suites, **18–37×** on parsing, and it edges happy-dom on repeated queries while
117
- staying 99.7% spec-correct.
115
+ On a realistic suite 200 files of construct + queries + events — turbo-dom is
116
+ **~40× happy-dom and ~120× jsdom**, edges happy-dom on repeated queries, and parses
117
+ **18–37×** faster, all at 99.7% conformance.
118
+
119
+ The per-file setup number is so high because the parser **memoizes the read-only
120
+ SoA buffer by HTML string**: a suite calls the env setup with the same document
121
+ shell every file, so it's parsed once and the buffer (never mutated — all changes
122
+ go to per-Document overlays) is reused. The first parse of a given shell pays full
123
+ cost (the parse rows above); every reuse is near-free.
118
124
 
119
125
  **turbo-dom wins across the board on what test suites actually do**: per-file
120
- construction (~10× happy-dom, ~23× jsdom), parsing, realistic suites (~10× happy-dom,
121
- ~23× jsdom), spec-correctness (99.7% vs 37%), **and** repeated queries.
126
+ construction (~40× happy-dom, ~120× jsdom on a repeated-shell suite), parsing,
127
+ spec-correctness (99.7% vs 37%), **and** repeated queries.
122
128
 
123
129
  How the query speed holds up against happy-dom (whose whole design trades correctness
124
130
  for query speed): the selector/match engine is allocation-free on the hot paths (no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Faster, more spec-correct DOM for test runners — native html5ever (Rust/WASM) parser + lazy copy-on-write DOM. A drop-in-style alternative to jsdom/happy-dom for vitest & jest.",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
@@ -5,23 +5,32 @@ function makeLive(getArray, extra = {}) {
5
5
  const target = function () {};
6
6
  return new Proxy(target, {
7
7
  get(_t, key) {
8
- const arr = getArray();
9
- if (key === 'length') return arr.length;
10
- if (key === 'item') return (i) => arr[i] ?? null;
11
- if (key === 'forEach') return (cb, thisArg) => arr.forEach(cb, thisArg);
12
- if (key === 'entries') return () => arr.entries();
13
- if (key === 'keys') return () => arr.keys();
14
- if (key === 'values') return () => arr[Symbol.iterator]();
15
- if (key === Symbol.iterator) return () => arr[Symbol.iterator]();
16
- if (key === 'toString') return () => '[object NodeList]';
17
- if (key in extra) return extra[key](arr);
18
- if (typeof key === 'string' && /^\d+$/.test(key)) return arr[Number(key)] ?? undefined;
8
+ if (typeof key === 'string') {
9
+ // hot path: indexed access coll[i]. A property key starting with a digit
10
+ // is a numeric index detect via charCode, no regex, no string-compare
11
+ // chain. getArray() is called only when actually needed.
12
+ const c = key.charCodeAt(0);
13
+ if (c >= 48 && c <= 57) return getArray()[+key];
14
+ if (key === 'length') return getArray().length;
15
+ if (key === 'item') return (i) => getArray()[i] ?? null;
16
+ if (key === 'forEach') return (cb, thisArg) => getArray().forEach(cb, thisArg);
17
+ if (key === 'entries') return () => getArray().entries();
18
+ if (key === 'keys') return () => getArray().keys();
19
+ if (key === 'values') return () => getArray()[Symbol.iterator]();
20
+ if (key === 'toString') return () => '[object NodeList]';
21
+ if (key in extra) return extra[key](getArray());
22
+ return undefined;
23
+ }
24
+ if (key === Symbol.iterator) return () => getArray()[Symbol.iterator]();
19
25
  return undefined;
20
26
  },
21
27
  has(_t, key) {
22
- const arr = getArray();
23
- if (typeof key === 'string' && /^\d+$/.test(key)) return Number(key) < arr.length;
24
- return key === 'length' || key === 'item' || key === 'forEach' || key in extra;
28
+ if (typeof key === 'string') {
29
+ const c = key.charCodeAt(0);
30
+ if (c >= 48 && c <= 57) return +key < getArray().length;
31
+ return key === 'length' || key === 'item' || key === 'forEach' || key in extra;
32
+ }
33
+ return false;
25
34
  },
26
35
  ownKeys() {
27
36
  const arr = getArray();
@@ -30,8 +39,11 @@ function makeLive(getArray, extra = {}) {
30
39
  getOwnPropertyDescriptor(_t, key) {
31
40
  const arr = getArray();
32
41
  if (key === 'length') return { configurable: true, enumerable: false, value: arr.length };
33
- if (typeof key === 'string' && /^\d+$/.test(key) && Number(key) < arr.length) {
34
- return { configurable: true, enumerable: true, value: arr[Number(key)] };
42
+ if (typeof key === 'string') {
43
+ const c = key.charCodeAt(0);
44
+ if (c >= 48 && c <= 57 && +key < arr.length) {
45
+ return { configurable: true, enumerable: true, value: arr[+key] };
46
+ }
35
47
  }
36
48
  return undefined;
37
49
  },
@@ -30,6 +30,18 @@ function cachedQSA(node, sel) {
30
30
  cache.set('a:' + sel, { v, r });
31
31
  return r;
32
32
  }
33
+ // memoize the className → class-list split (pure; the regex split showed up per
34
+ // getElementsByClassName call in profiles)
35
+ const __classSplit = new Map();
36
+ function splitClasses(cls) {
37
+ let c = __classSplit.get(cls);
38
+ if (c === undefined) {
39
+ c = cls.split(/\s+/).filter(Boolean);
40
+ if (__classSplit.size > 2000) __classSplit.clear();
41
+ __classSplit.set(cls, c);
42
+ }
43
+ return c;
44
+ }
33
45
  function cachedQS(node, sel) {
34
46
  const doc = node.ownerDocument || node;
35
47
  const v = doc.__version || 0;
@@ -327,6 +339,7 @@ export class Element extends Node {
327
339
  this.localName = localName;
328
340
  this.__ns = namespace; // '', 'svg', 'math'
329
341
  this.__attrs = []; // [{name, value, prefix}]
342
+ this.__attrIdx = -1; // buffer index for lazy attr inflation
330
343
  this.content = null; // <template> content fragment
331
344
  this.shadowRoot = null; // open shadow root, if attached
332
345
  }
@@ -337,10 +350,15 @@ export class Element extends Node {
337
350
  get namespaceURI() { return nsUri(this.__ns); }
338
351
 
339
352
  // ---- attributes ----
340
- getAttribute(name) { const at = this.__attrs; for (let i = 0; i < at.length; i++) if (at[i].name === name) return at[i].value; return null; }
341
- hasAttribute(name) { const at = this.__attrs; for (let i = 0; i < at.length; i++) if (at[i].name === name) return true; return false; }
342
- getAttributeNames() { return this.__attrs.map((a) => a.name); }
353
+ // attrs inflate lazily: a buffer-backed element leaves __attrs undefined and
354
+ // builds the array from the SoA only when an attribute is first touched (many
355
+ // elements are inflated for traversal but never have attrs read).
356
+ __buildAttrs() { const doc = this.ownerDocument, buf = doc && doc.__buf; return (this.__attrIdx >= 0 && buf) ? buf.attrs(this.__attrIdx) : []; }
357
+ getAttribute(name) { const at = this.__attrs ?? (this.__attrs = this.__buildAttrs()); for (let i = 0; i < at.length; i++) if (at[i].name === name) return at[i].value; return null; }
358
+ hasAttribute(name) { const at = this.__attrs ?? (this.__attrs = this.__buildAttrs()); for (let i = 0; i < at.length; i++) if (at[i].name === name) return true; return false; }
359
+ getAttributeNames() { return (this.__attrs ?? (this.__attrs = this.__buildAttrs())).map((a) => a.name); }
343
360
  setAttribute(name, value) {
361
+ if (this.__attrs === undefined) this.__attrs = this.__buildAttrs();
344
362
  const a = this.__attrs.find((x) => x.name === name);
345
363
  const old = a ? a.value : null;
346
364
  if (a) a.value = String(value);
@@ -348,6 +366,7 @@ export class Element extends Node {
348
366
  notifyMutation(this, { type: 'attributes', target: this, attributeName: name, oldValue: old, addedNodes: [], removedNodes: [] });
349
367
  }
350
368
  removeAttribute(name) {
369
+ if (this.__attrs === undefined) this.__attrs = this.__buildAttrs();
351
370
  const a = this.__attrs.find((x) => x.name === name);
352
371
  this.__attrs = this.__attrs.filter((x) => x.name !== name);
353
372
  if (a) notifyMutation(this, { type: 'attributes', target: this, attributeName: name, oldValue: a.value, addedNodes: [], removedNodes: [] });
@@ -358,7 +377,7 @@ export class Element extends Node {
358
377
  this.removeAttribute(name); return false;
359
378
  }
360
379
  get attributes() {
361
- return this.__attrs.map((a) => ({
380
+ return (this.__attrs ?? (this.__attrs = this.__buildAttrs())).map((a) => ({
362
381
  name: a.name, localName: a.name, value: a.value, prefix: a.prefix || null,
363
382
  namespaceURI: a.prefix === 'xlink' ? 'http://www.w3.org/1999/xlink' : null,
364
383
  }));
@@ -551,7 +570,7 @@ export class Element extends Node {
551
570
  querySelector(sel) { return cachedQS(this, sel); }
552
571
  querySelectorAll(sel) { return cachedQSA(this, sel); }
553
572
  getElementsByTagName(tag) { const self = this; return liveHTMLCollection(() => collectByTag(self, tag.toLowerCase())); }
554
- getElementsByClassName(cls) { const self = this; const classes = cls.split(/\s+/).filter(Boolean); return liveHTMLCollection(() => collectByClass(self, classes)); }
573
+ getElementsByClassName(cls) { const self = this; const classes = splitClasses(cls); return liveHTMLCollection(() => collectByClass(self, classes)); }
555
574
 
556
575
  // ---- innerHTML / outerHTML ----
557
576
  get innerHTML() { return serializeInner(this); }
@@ -586,7 +605,7 @@ export class Element extends Node {
586
605
 
587
606
  cloneNode(deep = false) {
588
607
  const el = new Element(this.ownerDocument, this.localName, this.__ns);
589
- el.__attrs = this.__attrs.map((a) => ({ ...a }));
608
+ el.__attrs = (this.__attrs ?? (this.__attrs = this.__buildAttrs())).map((a) => ({ ...a }));
590
609
  if (deep) for (const c of this.__children()) el.appendChild(c.cloneNode(true));
591
610
  return el;
592
611
  }
@@ -684,7 +703,7 @@ export class Element extends Node {
684
703
  setAttributeNS(_ns, name, value) { this.setAttribute(name, value); }
685
704
  hasAttributeNS(_ns, name) { return this.hasAttribute(name); }
686
705
  removeAttributeNS(_ns, name) { this.removeAttribute(name); }
687
- getAttributeNode(name) { const a = this.__attrs.find((x) => x.name === name); return a ? { name: a.name, value: a.value, ownerElement: this } : null; }
706
+ getAttributeNode(name) { const a = (this.__attrs ?? (this.__attrs = this.__buildAttrs())).find((x) => x.name === name); return a ? { name: a.name, value: a.value, ownerElement: this } : null; }
688
707
 
689
708
  // adjacency
690
709
  insertAdjacentElement(position, el) {
@@ -1087,7 +1106,7 @@ export class Document extends Node {
1087
1106
  case ELEMENT_NODE: {
1088
1107
  node = new Element(this, buf.tagName(idx), buf.ns(idx));
1089
1108
  node.__idx = idx;
1090
- node.__attrs = buf.attrs(idx);
1109
+ node.__attrIdx = idx; node.__attrs = undefined; // lazy: build on first attr access
1091
1110
  // template content fragment: a child node typed 11 named "content"
1092
1111
  if (buf.tagName(idx) === 'template') {
1093
1112
  for (let c = buf.firstChild(idx); c !== -1; c = buf.nextSib(c)) {
@@ -1266,7 +1285,7 @@ export class Document extends Node {
1266
1285
  return arr;
1267
1286
  }
1268
1287
  getElementsByTagName(tag) { const self = this; const t = tag.toLowerCase(); return liveHTMLCollection(() => self.__byTag(t)); }
1269
- getElementsByClassName(cls) { const self = this; const classes = cls.split(/\s+/).filter(Boolean); return liveHTMLCollection(() => self.__byClass(cls, classes)); }
1288
+ getElementsByClassName(cls) { const self = this; const classes = splitClasses(cls); return liveHTMLCollection(() => self.__byClass(cls, classes)); }
1270
1289
  contains(node) { return Node.prototype.contains.call(this, node); }
1271
1290
 
1272
1291
  // cookie jar: store name=value, strip attributes (path/Secure/SameSite/…),
@@ -114,13 +114,16 @@ function normalizeOptions(options) {
114
114
 
115
115
  export class EventTarget {
116
116
  constructor() {
117
- // type -> array of { callback, capture, once, passive }
118
- this.__listeners = new Map();
117
+ // type -> array of { callback, capture, once, passive }. Lazily created on
118
+ // first addEventListener — most inflated nodes never get a listener, so this
119
+ // skips a Map allocation per node (inflation is a top hot path).
120
+ this.__listeners = null;
119
121
  }
120
122
 
121
123
  addEventListener(type, callback, options) {
122
124
  if (callback == null) return;
123
125
  const o = normalizeOptions(options);
126
+ if (!this.__listeners) this.__listeners = new Map();
124
127
  let list = this.__listeners.get(type);
125
128
  if (!list) { list = []; this.__listeners.set(type, list); }
126
129
  // dedupe on (callback, capture) per spec
@@ -129,6 +132,7 @@ export class EventTarget {
129
132
  }
130
133
 
131
134
  removeEventListener(type, callback, options) {
135
+ if (!this.__listeners) return;
132
136
  const o = normalizeOptions(options);
133
137
  const list = this.__listeners.get(type);
134
138
  if (!list) return;
@@ -16,9 +16,24 @@ const native = require('../../index.js');
16
16
  export { Document } from './dom.mjs';
17
17
  export * from './dom.mjs';
18
18
 
19
+ // Parse cache: the SoA buffer is READ-ONLY (every mutation goes to a Document's
20
+ // own __kids/__attrs/__cache overlay, never the buffer), so the same buffer can
21
+ // back many Documents. Test suites call setup with the SAME html per file
22
+ // (usually the empty default) → parse once, reuse for every file, skipping the
23
+ // native parse + boundary marshaling entirely. Bounded (fixtures are few).
24
+ const __parseCache = new Map();
25
+ function parseBufferCached(html) {
26
+ const hit = __parseCache.get(html);
27
+ if (hit !== undefined) return hit;
28
+ const soa = native.parseBuffer(html);
29
+ if (__parseCache.size > 64) __parseCache.clear();
30
+ __parseCache.set(html, soa);
31
+ return soa;
32
+ }
33
+
19
34
  export function createEnvironment(html = '<!doctype html><html><head></head><body></body></html>', options = {}) {
20
35
  // Layer 1: native parse → immutable SoA buffer (typed arrays, one boundary copy).
21
- let soa = native.parseBuffer(String(html));
36
+ let soa = parseBufferCached(String(html));
22
37
 
23
38
  // Layer 2: Document over the buffer (nodes inflate lazily from the arrays).
24
39
  const document = new Document();
@@ -36,7 +51,7 @@ export function createEnvironment(html = '<!doctype html><html><head></head><bod
36
51
  // Layer 5: arena-style reset. Re-point at the (re)parsed buffer, drop the
37
52
  // owned overlay + node cache + materialized globals. Class machinery stays warm.
38
53
  reset(nextHtml) {
39
- if (nextHtml !== undefined) soa = native.parseBuffer(String(nextHtml));
54
+ if (nextHtml !== undefined) soa = parseBufferCached(String(nextHtml));
40
55
  document.__load(soa); // drops __cache + __kids overlay, keeps the buffer if reused
41
56
  win.resetGlobals();
42
57
  document.__active = null;