@miaskiewicz/turbo-dom 0.1.18 → 0.1.20

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 jsdom** — ~23× lower per-file setup, ~6× on query-heavy DOM work, 18–37× faster HTML parsing.
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.
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
 
@@ -100,21 +100,29 @@ adopted yet.
100
100
 
101
101
  ## Performance
102
102
 
103
- Measured on darwin-arm64, Node 24 (`npm run bench:all`):
104
-
105
- | benchmark | turbo-dom | happy-dom | jsdom | vs jsdom |
106
- |---|---:|---:|---:|---:|
107
- | per-file setup + 1 query (ops/s) | **5,950** | 611 | 260 | **22.9×** |
108
- | full suite, 200 files (ms/file) | **0.13** | 1.50 | 3.38 | **23.6×** |
109
- | query-heavy DOM work (iters/s) | **18,125** | | 3,089 | **5.9×** |
110
- | parse 56 KB SSR (ops/s) | **478** | 43 | 26 | **18×** |
111
- | parse 20 KB real page (ops/s) | **4,203** | 190 | 114 | **37×** |
112
-
113
- Why it's fast: parsing is native; the JS DOM doesn't allocate node objects for parts of the
114
- tree a test never reads; `window` doesn't build the ~12 globals (storage, observers,
115
- matchMedia…) a render-only test never touches; and the selector/match engine is allocation-free
116
- on the hot paths (no per-element `classList`/`split`/regex), so `querySelectorAll` and the
117
- `getElementsBy*` collections that RTL leans on stay cheap.
103
+ Measured on darwin-arm64, Node 24 (`npm run bench:all`). Higher = faster, except
104
+ the suite row (ms/file, lower = faster):
105
+
106
+ | benchmark | turbo-dom | happy-dom | jsdom |
107
+ |---|---:|---:|---:|
108
+ | **per-file setup + 1 query** (ops/s) | **5,950** | 611 | 260 |
109
+ | **realistic suite**, 200 files (ms/file) | **0.13** | 1.50 | 3.38 |
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) | **915k** | 615k | 3k |
113
+ | html5lib conformance | **99.72%** | 37.35% | 97.03% |
114
+
115
+ **turbo-dom wins across the board on what test suites actually do**: per-file
116
+ construction (~10× happy-dom, ~23× jsdom), parsing, realistic suites (~10× happy-dom,
117
+ ~23× jsdom), spec-correctness (99.7% vs 37%), **and** repeated queries.
118
+
119
+ How the query speed holds up against happy-dom (whose whole design trades correctness
120
+ for query speed): the selector/match engine is allocation-free on the hot paths (no
121
+ per-element `classList`/`split`/regex), and `querySelectorAll`/`getElementsBy*`/
122
+ `getElementById` results are **cached per (selector, DOM-version)** — a static
123
+ `querySelectorAll` list is safe to reuse until the next mutation. So the repeated
124
+ queries RTL/`findBy`/`waitFor` run against an unchanged tree are near-free, and
125
+ `getByLabelText` went from O(n²) (1.3 ms) to ~270 µs.
118
126
 
119
127
  ## How it works
120
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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",
@@ -14,6 +14,33 @@ import {
14
14
  import { liveNodeList, liveHTMLCollection } from './collections.mjs';
15
15
  import { matchesSelector, querySelector as qsel, querySelectorAll as qselAll } from './selectors.mjs';
16
16
  import { serializeInner, serializeOuter } from './html-serialize.mjs';
17
+
18
+ // Per-node query-result cache keyed by (selector, document version). querySelectorAll
19
+ // returns a STATIC list per spec, so caching is safe until the next mutation bumps
20
+ // Document.__version (which invalidates every cached query). Big win for the repeated
21
+ // identical queries RTL/findBy/waitFor run against an unchanged tree.
22
+ function cachedQSA(node, sel) {
23
+ const doc = node.ownerDocument || node;
24
+ const v = doc.__version || 0;
25
+ let cache = node.__qCache;
26
+ if (cache) { const c = cache.get('a:' + sel); if (c !== undefined && c.v === v) return c.r; }
27
+ else cache = node.__qCache = new Map();
28
+ if (cache.size > 512) cache.clear();
29
+ const r = qselAll(node, sel);
30
+ cache.set('a:' + sel, { v, r });
31
+ return r;
32
+ }
33
+ function cachedQS(node, sel) {
34
+ const doc = node.ownerDocument || node;
35
+ const v = doc.__version || 0;
36
+ let cache = node.__qCache;
37
+ if (cache) { const c = cache.get('s:' + sel); if (c !== undefined && c.v === v) return c.r; }
38
+ else cache = node.__qCache = new Map();
39
+ if (cache.size > 512) cache.clear();
40
+ const r = qsel(node, sel);
41
+ cache.set('s:' + sel, { v, r });
42
+ return r;
43
+ }
17
44
  import { Buffer } from './buffer.mjs';
18
45
  import { makeCanvasStub } from './stubs.mjs';
19
46
 
@@ -158,7 +185,7 @@ export class Node extends EventTarget {
158
185
  else if (kids[i].nodeType === ELEMENT_NODE) kids[i].normalize();
159
186
  }
160
187
  }
161
- replaceChildren(...nodes) { this.__kids = []; for (const n of nodes) this.appendChild(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n); }
188
+ replaceChildren(...nodes) { this.__kids = []; this.__touch(); for (const n of nodes) this.appendChild(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n); }
162
189
  // bitmask: 1 DISCONNECTED, 2 PRECEDING, 4 FOLLOWING, 8 CONTAINS, 16 CONTAINED_BY
163
190
  compareDocumentPosition(other) {
164
191
  if (other === this) return 0;
@@ -189,9 +216,15 @@ export class Node extends EventTarget {
189
216
  }
190
217
  set textContent(value) {
191
218
  this.__kids = [];
219
+ this.__touch();
192
220
  if (value !== '') this.appendChild(this.ownerDocument.createTextNode(String(value)));
193
221
  }
194
222
 
223
+ // bump the document version so getElementsBy* caches invalidate after direct
224
+ // __kids reassignments (innerHTML/textContent/replaceChildren) that bypass
225
+ // insertBefore/removeChild.
226
+ __touch() { const d = this.ownerDocument; if (d) d.__version = (d.__version || 0) + 1; }
227
+
195
228
  // window/document path for event propagation past the document
196
229
  get __owner() { return null; }
197
230
  }
@@ -505,13 +538,13 @@ export class Element extends Node {
505
538
  before(...nodes) { const p = this.parentNode; if (!p) return; for (const n of nodes) p.insertBefore(toNode(this.ownerDocument, n), this); }
506
539
  after(...nodes) { const p = this.parentNode; if (!p) return; const ref = this.nextSibling; for (const n of nodes) p.insertBefore(toNode(this.ownerDocument, n), ref); }
507
540
  replaceWith(...nodes) { const p = this.parentNode; if (!p) return; const ref = this.nextSibling; this.remove(); for (const n of nodes) p.insertBefore(toNode(this.ownerDocument, n), ref); }
508
- replaceChildren(...nodes) { this.__kids = []; for (const n of nodes) this.appendChild(toNode(this.ownerDocument, n)); }
541
+ replaceChildren(...nodes) { this.__kids = []; this.__touch(); for (const n of nodes) this.appendChild(toNode(this.ownerDocument, n)); }
509
542
 
510
543
  // ---- queries ----
511
544
  matches(sel) { return matchesSelector(this, sel); }
512
545
  closest(sel) { let n = this; while (n && n.nodeType === ELEMENT_NODE) { if (n.matches(sel)) return n; n = n.parentNode; } return null; }
513
- querySelector(sel) { return qsel(this, sel); }
514
- querySelectorAll(sel) { return qselAll(this, sel); }
546
+ querySelector(sel) { return cachedQS(this, sel); }
547
+ querySelectorAll(sel) { return cachedQSA(this, sel); }
515
548
  getElementsByTagName(tag) { const self = this; return liveHTMLCollection(() => collectByTag(self, tag.toLowerCase())); }
516
549
  getElementsByClassName(cls) { const self = this; const classes = cls.split(/\s+/).filter(Boolean); return liveHTMLCollection(() => collectByClass(self, classes)); }
517
550
 
@@ -520,6 +553,7 @@ export class Element extends Node {
520
553
  set innerHTML(html) {
521
554
  const frag = native.parseFragment(String(html), this.__ns ? `${this.__ns} ${this.localName}` : this.localName);
522
555
  this.__kids = [];
556
+ this.__touch();
523
557
  for (const rawChild of frag.children) {
524
558
  if (rawChild.nodeType === DOCUMENT_FRAGMENT_NODE && rawChild.name === 'content') continue;
525
559
  const child = this.ownerDocument.__inflateNested(rawChild);
@@ -714,8 +748,8 @@ export class Element extends Node {
714
748
  export class DocumentFragment extends Node {
715
749
  get nodeType() { return DOCUMENT_FRAGMENT_NODE; }
716
750
  get nodeName() { return '#document-fragment'; }
717
- querySelector(sel) { return qsel(this, sel); }
718
- querySelectorAll(sel) { return qselAll(this, sel); }
751
+ querySelector(sel) { return cachedQS(this, sel); }
752
+ querySelectorAll(sel) { return cachedQSA(this, sel); }
719
753
  get children() { const self = this; return liveHTMLCollection(() => self.__children().filter((n) => n.nodeType === ELEMENT_NODE)); }
720
754
  append(...nodes) { for (const n of nodes) this.appendChild(toNode(this.ownerDocument, n)); }
721
755
  cloneNode(deep = false) { const f = new DocumentFragment(this.ownerDocument); if (deep) for (const c of this.__children()) f.appendChild(c.cloneNode(true)); return f; }
@@ -956,7 +990,11 @@ function isDescendant(node, ancestor) {
956
990
  }
957
991
  function notifyMutation(target, record) {
958
992
  const doc = target.ownerDocument;
959
- if (!doc || !doc.__mo || doc.__mo.length === 0) return;
993
+ if (!doc) return;
994
+ // bump the DOM version on every structural/attribute mutation — invalidates
995
+ // the document's getElementsBy* caches. Unconditional (independent of observers).
996
+ doc.__version = (doc.__version || 0) + 1;
997
+ if (!doc.__mo || doc.__mo.length === 0) return;
960
998
  for (const reg of doc.__mo) {
961
999
  const { obs, target: obsTarget, options } = reg;
962
1000
  const onTarget = record.target === obsTarget;
@@ -1109,6 +1147,9 @@ export class Document extends Node {
1109
1147
  this.__active = null;
1110
1148
  this.__mo = []; // drop observers
1111
1149
  this.__moPending = null;
1150
+ this.__version = (this.__version || 0) + 1; // invalidate getElementsBy* caches
1151
+ this.__tagCache = null;
1152
+ this.__classCache = null;
1112
1153
  }
1113
1154
 
1114
1155
  get documentElement() { return this.__children().find((n) => n.nodeType === ELEMENT_NODE && n.localName === 'html') ?? null; }
@@ -1179,20 +1220,48 @@ export class Document extends Node {
1179
1220
 
1180
1221
  // ---- queries ----
1181
1222
  getElementById(id) {
1223
+ const v = this.__version || 0;
1224
+ const cache = this.__idCache;
1225
+ if (cache) { const c = cache.get(id); if (c !== undefined && c.v === v) return c.el; }
1182
1226
  let found = null;
1183
1227
  const visit = (node) => {
1184
- for (const c of node.__children()) {
1228
+ const kids = node.__children();
1229
+ for (let i = 0; i < kids.length; i++) {
1230
+ const c = kids[i];
1231
+ if (c.nodeType !== ELEMENT_NODE) continue;
1232
+ if (c.getAttribute('id') === id) { found = c; return; }
1233
+ visit(c);
1185
1234
  if (found) return;
1186
- if (c.nodeType === ELEMENT_NODE) { if (c.getAttribute('id') === id) { found = c; return; } visit(c); }
1187
1235
  }
1188
1236
  };
1189
1237
  visit(this);
1238
+ (this.__idCache || (this.__idCache = new Map())).set(id, { v, el: found });
1190
1239
  return found;
1191
1240
  }
1192
- querySelector(sel) { return qsel(this, sel); }
1193
- querySelectorAll(sel) { return qselAll(this, sel); }
1194
- getElementsByTagName(tag) { const self = this; return liveHTMLCollection(() => collectByTag(self, tag.toLowerCase())); }
1195
- getElementsByClassName(cls) { const self = this; const classes = cls.split(/\s+/).filter(Boolean); return liveHTMLCollection(() => collectByClass(self, classes)); }
1241
+ querySelector(sel) { return cachedQS(this, sel); }
1242
+ querySelectorAll(sel) { return cachedQSA(this, sel); }
1243
+ // version-keyed cache: getElementsBy* is called repeatedly within a single
1244
+ // query (e.g. RTL getByLabelText calls element.labels per element, each doing
1245
+ // document.getElementsByTagName('label')). Without caching that's O(n²) tree
1246
+ // walks. The cache invalidates whenever the DOM version bumps (any mutation).
1247
+ __byTag(t) {
1248
+ const v = this.__version || 0;
1249
+ const c = this.__tagCache && this.__tagCache.get(t);
1250
+ if (c && c.v === v) return c.arr;
1251
+ const arr = collectByTag(this, t);
1252
+ (this.__tagCache || (this.__tagCache = new Map())).set(t, { v, arr });
1253
+ return arr;
1254
+ }
1255
+ __byClass(key, classes) {
1256
+ const v = this.__version || 0;
1257
+ const c = this.__classCache && this.__classCache.get(key);
1258
+ if (c && c.v === v) return c.arr;
1259
+ const arr = collectByClass(this, classes);
1260
+ (this.__classCache || (this.__classCache = new Map())).set(key, { v, arr });
1261
+ return arr;
1262
+ }
1263
+ getElementsByTagName(tag) { const self = this; const t = tag.toLowerCase(); return liveHTMLCollection(() => self.__byTag(t)); }
1264
+ getElementsByClassName(cls) { const self = this; const classes = cls.split(/\s+/).filter(Boolean); return liveHTMLCollection(() => self.__byClass(cls, classes)); }
1196
1265
  contains(node) { return Node.prototype.contains.call(this, node); }
1197
1266
 
1198
1267
  // cookie jar: store name=value, strip attributes (path/Secure/SameSite/…),