@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 +24 -16
- package/package.json +1 -1
- package/src/runtime/dom.mjs +43 -8
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
|
109
|
-
|
|
|
110
|
-
| parse 56 KB SSR (ops/s) | **478** | 43 | 26 |
|
|
111
|
-
| parse 20 KB real page (ops/s) | **4,203** | 190 | 114 |
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
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",
|
package/src/runtime/dom.mjs
CHANGED
|
@@ -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
|
|
520
|
-
querySelectorAll(sel) { return
|
|
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
|
|
725
|
-
querySelectorAll(sel) { return
|
|
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
|
-
|
|
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
|
|
1207
|
-
querySelectorAll(sel) { return
|
|
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
|
|
Binary file
|