@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 +24 -16
- package/package.json +1 -1
- package/src/runtime/dom.mjs +82 -13
- 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
|
|
|
@@ -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
|
|
514
|
-
querySelectorAll(sel) { return
|
|
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
|
|
718
|
-
querySelectorAll(sel) { return
|
|
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
|
|
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
|
-
|
|
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
|
|
1193
|
-
querySelectorAll(sel) { return
|
|
1194
|
-
|
|
1195
|
-
|
|
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/…),
|
|
Binary file
|