@miaskiewicz/turbo-dom 0.1.19 → 0.1.21
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 +28 -16
- package/package.json +1 -1
- package/src/runtime/dom.mjs +51 -11
- package/src/runtime/events.mjs +28 -13
- package/src/runtime/stubs.mjs +8 -1
- package/src/runtime/window.mjs +5 -2
- 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,33 @@ 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
|
-
on
|
|
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,900** | 600 | 250 |
|
|
109
|
+
| **realistic suite**, 200 files (ms/file) | **0.15** | 2.1 | 4.9 |
|
|
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 |
|
|
113
|
+
| html5lib conformance | **99.72%** | 37.35% | 97.03% |
|
|
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.
|
|
118
|
+
|
|
119
|
+
**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.
|
|
122
|
+
|
|
123
|
+
How the query speed holds up against happy-dom (whose whole design trades correctness
|
|
124
|
+
for query speed): the selector/match engine is allocation-free on the hot paths (no
|
|
125
|
+
per-element `classList`/`split`/regex), and `querySelectorAll`/`getElementsBy*`/
|
|
126
|
+
`getElementById` results are **cached per (selector, DOM-version)** — a static
|
|
127
|
+
`querySelectorAll` list is safe to reuse until the next mutation. So the repeated
|
|
128
|
+
queries RTL/`findBy`/`waitFor` run against an unchanged tree are near-free, and
|
|
129
|
+
`getByLabelText` went from O(n²) (1.3 ms) to ~270 µs.
|
|
118
130
|
|
|
119
131
|
## How it works
|
|
120
132
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
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
|
|
|
@@ -71,8 +98,12 @@ export class Node extends EventTarget {
|
|
|
71
98
|
}
|
|
72
99
|
|
|
73
100
|
get childNodes() {
|
|
101
|
+
// the NodeList reads __children() live, so one cached object per node is
|
|
102
|
+
// always correct — avoids re-allocating a Proxy on every .childNodes access
|
|
103
|
+
// (React/RTL hit this constantly).
|
|
104
|
+
if (this.__childNodesList) return this.__childNodesList;
|
|
74
105
|
const self = this;
|
|
75
|
-
return liveNodeList(() => self.__children());
|
|
106
|
+
return (this.__childNodesList = liveNodeList(() => self.__children()));
|
|
76
107
|
}
|
|
77
108
|
get firstChild() { const k = this.__children(); return k[0] ?? null; }
|
|
78
109
|
get lastChild() { const k = this.__children(); return k[k.length - 1] ?? null; }
|
|
@@ -496,8 +527,9 @@ export class Element extends Node {
|
|
|
496
527
|
|
|
497
528
|
// ---- element-only traversal (live) ----
|
|
498
529
|
get children() {
|
|
530
|
+
if (this.__childrenList) return this.__childrenList;
|
|
499
531
|
const self = this;
|
|
500
|
-
return liveHTMLCollection(() => self.__children().filter((n) => n.nodeType === ELEMENT_NODE));
|
|
532
|
+
return (this.__childrenList = liveHTMLCollection(() => self.__children().filter((n) => n.nodeType === ELEMENT_NODE)));
|
|
501
533
|
}
|
|
502
534
|
get childElementCount() { return this.__children().filter((n) => n.nodeType === ELEMENT_NODE).length; }
|
|
503
535
|
get firstElementChild() { return this.__children().find((n) => n.nodeType === ELEMENT_NODE) ?? null; }
|
|
@@ -516,8 +548,8 @@ export class Element extends Node {
|
|
|
516
548
|
// ---- queries ----
|
|
517
549
|
matches(sel) { return matchesSelector(this, sel); }
|
|
518
550
|
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
|
|
551
|
+
querySelector(sel) { return cachedQS(this, sel); }
|
|
552
|
+
querySelectorAll(sel) { return cachedQSA(this, sel); }
|
|
521
553
|
getElementsByTagName(tag) { const self = this; return liveHTMLCollection(() => collectByTag(self, tag.toLowerCase())); }
|
|
522
554
|
getElementsByClassName(cls) { const self = this; const classes = cls.split(/\s+/).filter(Boolean); return liveHTMLCollection(() => collectByClass(self, classes)); }
|
|
523
555
|
|
|
@@ -721,9 +753,9 @@ export class Element extends Node {
|
|
|
721
753
|
export class DocumentFragment extends Node {
|
|
722
754
|
get nodeType() { return DOCUMENT_FRAGMENT_NODE; }
|
|
723
755
|
get nodeName() { return '#document-fragment'; }
|
|
724
|
-
querySelector(sel) { return
|
|
725
|
-
querySelectorAll(sel) { return
|
|
726
|
-
get children() { const self = this; return liveHTMLCollection(() => self.__children().filter((n) => n.nodeType === ELEMENT_NODE)); }
|
|
756
|
+
querySelector(sel) { return cachedQS(this, sel); }
|
|
757
|
+
querySelectorAll(sel) { return cachedQSA(this, sel); }
|
|
758
|
+
get children() { if (this.__childrenList) return this.__childrenList; const self = this; return (this.__childrenList = liveHTMLCollection(() => self.__children().filter((n) => n.nodeType === ELEMENT_NODE))); }
|
|
727
759
|
append(...nodes) { for (const n of nodes) this.appendChild(toNode(this.ownerDocument, n)); }
|
|
728
760
|
cloneNode(deep = false) { const f = new DocumentFragment(this.ownerDocument); if (deep) for (const c of this.__children()) f.appendChild(c.cloneNode(true)); return f; }
|
|
729
761
|
}
|
|
@@ -1193,18 +1225,26 @@ export class Document extends Node {
|
|
|
1193
1225
|
|
|
1194
1226
|
// ---- queries ----
|
|
1195
1227
|
getElementById(id) {
|
|
1228
|
+
const v = this.__version || 0;
|
|
1229
|
+
const cache = this.__idCache;
|
|
1230
|
+
if (cache) { const c = cache.get(id); if (c !== undefined && c.v === v) return c.el; }
|
|
1196
1231
|
let found = null;
|
|
1197
1232
|
const visit = (node) => {
|
|
1198
|
-
|
|
1233
|
+
const kids = node.__children();
|
|
1234
|
+
for (let i = 0; i < kids.length; i++) {
|
|
1235
|
+
const c = kids[i];
|
|
1236
|
+
if (c.nodeType !== ELEMENT_NODE) continue;
|
|
1237
|
+
if (c.getAttribute('id') === id) { found = c; return; }
|
|
1238
|
+
visit(c);
|
|
1199
1239
|
if (found) return;
|
|
1200
|
-
if (c.nodeType === ELEMENT_NODE) { if (c.getAttribute('id') === id) { found = c; return; } visit(c); }
|
|
1201
1240
|
}
|
|
1202
1241
|
};
|
|
1203
1242
|
visit(this);
|
|
1243
|
+
(this.__idCache || (this.__idCache = new Map())).set(id, { v, el: found });
|
|
1204
1244
|
return found;
|
|
1205
1245
|
}
|
|
1206
|
-
querySelector(sel) { return
|
|
1207
|
-
querySelectorAll(sel) { return
|
|
1246
|
+
querySelector(sel) { return cachedQS(this, sel); }
|
|
1247
|
+
querySelectorAll(sel) { return cachedQSA(this, sel); }
|
|
1208
1248
|
// version-keyed cache: getElementsBy* is called repeatedly within a single
|
|
1209
1249
|
// query (e.g. RTL getByLabelText calls element.labels per element, each doing
|
|
1210
1250
|
// document.getElementsByTagName('label')). Without caching that's O(n²) tree
|
package/src/runtime/events.mjs
CHANGED
|
@@ -150,8 +150,20 @@ export class EventTarget {
|
|
|
150
150
|
dispatchEvent(event) {
|
|
151
151
|
if (!(event instanceof Event)) throw new TypeError('dispatchEvent requires an Event');
|
|
152
152
|
event.target = this;
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
|
|
154
|
+
// Single ancestor walk: build the path AND note whether any node on it has a
|
|
155
|
+
// listener for this type. React fires thousands of events with zero matching
|
|
156
|
+
// listeners on the path — those skip the capture/target/bubble invoke loops.
|
|
157
|
+
const type = event.type;
|
|
158
|
+
const path = [];
|
|
159
|
+
let hasListener = false;
|
|
160
|
+
let node = this;
|
|
161
|
+
while (node) {
|
|
162
|
+
path.push(node);
|
|
163
|
+
if (!hasListener) { const l = node.__listeners && node.__listeners.get(type); if (l && l.length) hasListener = true; }
|
|
164
|
+
node = node.parentNode || node.__owner || null;
|
|
165
|
+
}
|
|
166
|
+
event._path = path;
|
|
155
167
|
|
|
156
168
|
// pre-click activation (WHATWG): checkbox/radio toggle BEFORE click listeners
|
|
157
169
|
// run, so React's change detection sees the new value. Undone if preventDefault.
|
|
@@ -185,18 +197,21 @@ export class EventTarget {
|
|
|
185
197
|
}
|
|
186
198
|
};
|
|
187
199
|
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
// at target
|
|
194
|
-
if (!event._stopPropagation) invoke(path[0], PHASE_AT_TARGET);
|
|
195
|
-
// bubbling: target's parent -> root
|
|
196
|
-
if (event.bubbles) {
|
|
197
|
-
for (let i = 1; i < path.length; i++) {
|
|
200
|
+
// no listener anywhere on the path → skip all three propagation phases
|
|
201
|
+
if (hasListener) {
|
|
202
|
+
// capturing: root -> just before target
|
|
203
|
+
for (let i = path.length - 1; i >= 1; i--) {
|
|
198
204
|
if (event._stopPropagation) break;
|
|
199
|
-
invoke(path[i],
|
|
205
|
+
invoke(path[i], PHASE_CAPTURING);
|
|
206
|
+
}
|
|
207
|
+
// at target
|
|
208
|
+
if (!event._stopPropagation) invoke(path[0], PHASE_AT_TARGET);
|
|
209
|
+
// bubbling: target's parent -> root
|
|
210
|
+
if (event.bubbles) {
|
|
211
|
+
for (let i = 1; i < path.length; i++) {
|
|
212
|
+
if (event._stopPropagation) break;
|
|
213
|
+
invoke(path[i], PHASE_BUBBLING);
|
|
214
|
+
}
|
|
200
215
|
}
|
|
201
216
|
}
|
|
202
217
|
|
package/src/runtime/stubs.mjs
CHANGED
|
@@ -83,8 +83,13 @@ export function makeMatchMedia() {
|
|
|
83
83
|
// invents cascade/layout numbers. A property that wasn't set reads as ''.
|
|
84
84
|
export function makeGetComputedStyle() {
|
|
85
85
|
return (el) => {
|
|
86
|
+
// The Proxy reads el.style LIVE on each access, so one cached Proxy per
|
|
87
|
+
// element is always correct (no version needed) — avoids re-allocating a
|
|
88
|
+
// Proxy on every call, which dom-accessibility-api does per element in
|
|
89
|
+
// getByRole/getByText visibility checks.
|
|
90
|
+
if (el && el.__computedStyle) return el.__computedStyle;
|
|
86
91
|
const style = el && el.style ? el.style : null;
|
|
87
|
-
|
|
92
|
+
const proxy = new Proxy({}, {
|
|
88
93
|
get(_t, key) {
|
|
89
94
|
if (key === 'getPropertyValue') return (p) => (style ? style.getPropertyValue(p) : '');
|
|
90
95
|
if (key === '__honest') return 'computed style is inline-only; no layout/cascade available';
|
|
@@ -92,6 +97,8 @@ export function makeGetComputedStyle() {
|
|
|
92
97
|
return style ? style[key] : '';
|
|
93
98
|
},
|
|
94
99
|
});
|
|
100
|
+
if (el) el.__computedStyle = proxy;
|
|
101
|
+
return proxy;
|
|
95
102
|
};
|
|
96
103
|
}
|
|
97
104
|
|
package/src/runtime/window.mjs
CHANGED
|
@@ -117,8 +117,8 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
|
|
|
117
117
|
HTMLHeadingElement: tagClass(/^h[1-6]$/),
|
|
118
118
|
HTMLDocument: Document, DocumentFragment, ShadowRoot: DocumentFragment,
|
|
119
119
|
MutationObserver, DOMParser, XMLSerializer,
|
|
120
|
-
URL:
|
|
121
|
-
Blob: globalThis.Blob, File:
|
|
120
|
+
URL: TURBO_URL, URLSearchParams,
|
|
121
|
+
Blob: globalThis.Blob, File: TURBO_FILE, FileReader,
|
|
122
122
|
customElements: makeCustomElements(),
|
|
123
123
|
AbortController: globalThis.AbortController, AbortSignal: globalThis.AbortSignal,
|
|
124
124
|
TextEncoder: globalThis.TextEncoder, TextDecoder: globalThis.TextDecoder,
|
|
@@ -247,6 +247,9 @@ function makeFile() {
|
|
|
247
247
|
constructor(bits = [], name = 'file', opts = {}) { super(bits, opts); this.name = String(name); this.lastModified = opts.lastModified || 0; }
|
|
248
248
|
};
|
|
249
249
|
}
|
|
250
|
+
// stateless — build the classes ONCE, not per createWindow() (per test file).
|
|
251
|
+
const TURBO_URL = makeURL();
|
|
252
|
+
const TURBO_FILE = makeFile();
|
|
250
253
|
|
|
251
254
|
// Minimal XMLHttpRequest backed by fetch — enough that libraries that construct
|
|
252
255
|
// one and issue a request don't crash. No-network setups still get a clean object.
|
|
Binary file
|