@miaskiewicz/turbo-dom 0.1.19 → 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.19",
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
 
@@ -516,8 +543,8 @@ export class Element extends Node {
516
543
  // ---- queries ----
517
544
  matches(sel) { return matchesSelector(this, sel); }
518
545
  closest(sel) { let n = this; while (n && n.nodeType === ELEMENT_NODE) { if (n.matches(sel)) return n; n = n.parentNode; } return null; }
519
- querySelector(sel) { return qsel(this, sel); }
520
- querySelectorAll(sel) { return qselAll(this, sel); }
546
+ querySelector(sel) { return cachedQS(this, sel); }
547
+ querySelectorAll(sel) { return cachedQSA(this, sel); }
521
548
  getElementsByTagName(tag) { const self = this; return liveHTMLCollection(() => collectByTag(self, tag.toLowerCase())); }
522
549
  getElementsByClassName(cls) { const self = this; const classes = cls.split(/\s+/).filter(Boolean); return liveHTMLCollection(() => collectByClass(self, classes)); }
523
550
 
@@ -721,8 +748,8 @@ export class Element extends Node {
721
748
  export class DocumentFragment extends Node {
722
749
  get nodeType() { return DOCUMENT_FRAGMENT_NODE; }
723
750
  get nodeName() { return '#document-fragment'; }
724
- querySelector(sel) { return qsel(this, sel); }
725
- querySelectorAll(sel) { return qselAll(this, sel); }
751
+ querySelector(sel) { return cachedQS(this, sel); }
752
+ querySelectorAll(sel) { return cachedQSA(this, sel); }
726
753
  get children() { const self = this; return liveHTMLCollection(() => self.__children().filter((n) => n.nodeType === ELEMENT_NODE)); }
727
754
  append(...nodes) { for (const n of nodes) this.appendChild(toNode(this.ownerDocument, n)); }
728
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; }
@@ -1193,18 +1220,26 @@ export class Document extends Node {
1193
1220
 
1194
1221
  // ---- queries ----
1195
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; }
1196
1226
  let found = null;
1197
1227
  const visit = (node) => {
1198
- 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);
1199
1234
  if (found) return;
1200
- if (c.nodeType === ELEMENT_NODE) { if (c.getAttribute('id') === id) { found = c; return; } visit(c); }
1201
1235
  }
1202
1236
  };
1203
1237
  visit(this);
1238
+ (this.__idCache || (this.__idCache = new Map())).set(id, { v, el: found });
1204
1239
  return found;
1205
1240
  }
1206
- querySelector(sel) { return qsel(this, sel); }
1207
- querySelectorAll(sel) { return qselAll(this, sel); }
1241
+ querySelector(sel) { return cachedQS(this, sel); }
1242
+ querySelectorAll(sel) { return cachedQSA(this, sel); }
1208
1243
  // version-keyed cache: getElementsBy* is called repeatedly within a single
1209
1244
  // query (e.g. RTL getByLabelText calls element.labels per element, each doing
1210
1245
  // document.getElementsByTagName('label')). Without caching that's O(n²) tree