@miaskiewicz/turbo-dom 0.1.17 → 0.1.19

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** — ~19× lower per-file setup, 1139× faster HTML parsing.
17
+ - ⚡ **Faster than jsdom** — ~23× lower per-file setup, ~6× on query-heavy DOM work, 1837× faster HTML parsing.
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
 
@@ -102,16 +102,19 @@ adopted yet.
102
102
 
103
103
  Measured on darwin-arm64, Node 24 (`npm run bench:all`):
104
104
 
105
- | benchmark | turbo-dom | happy-dom | jsdom |
106
- |---|---:|---:|---:|
107
- | per-file setup + 1 query (ops/s) | **6,808** | 526 | 266 |
108
- | full suite, 200 files (ms/file) | **0.13** | 1.45 | 3.36 |
109
- | parse 56 KB SSR (ops/s) | **502** | 46 | 23 |
110
- | parse 20 KB real page (ops/s) | **3,912** | 230 | 100 |
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×** |
111
112
 
112
113
  Why it's fast: parsing is native; the JS DOM doesn't allocate node objects for parts of the
113
- tree a test never reads; and `window` doesn't build the ~12 globals (storage, observers,
114
- matchMedia…) a render-only test never touches.
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.
115
118
 
116
119
  ## How it works
117
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
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",
@@ -64,10 +64,11 @@
64
64
  "bench:construct": "node bench/construct.mjs",
65
65
  "bench:suite": "node bench/suite.mjs",
66
66
  "bench:wasm": "node bench/wasm.mjs",
67
- "bench:all": "node bench/parse.mjs && node bench/construct.mjs && node bench/suite.mjs && node bench/wasm.mjs",
67
+ "bench:all": "node bench/parse.mjs && node bench/construct.mjs && node bench/suite.mjs && node bench/query.mjs && node bench/wasm.mjs",
68
68
  "prepublishOnly": "napi build --platform --release",
69
69
  "test:vitest": "vitest run -c test-vitest/vitest.config.mjs",
70
- "test:jest": "jest -c test-jest/jest.config.cjs"
70
+ "test:jest": "jest -c test-jest/jest.config.cjs",
71
+ "bench:query": "node bench/query.mjs"
71
72
  },
72
73
  "keywords": [
73
74
  "dom",
@@ -158,7 +158,7 @@ export class Node extends EventTarget {
158
158
  else if (kids[i].nodeType === ELEMENT_NODE) kids[i].normalize();
159
159
  }
160
160
  }
161
- replaceChildren(...nodes) { this.__kids = []; for (const n of nodes) this.appendChild(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n); }
161
+ replaceChildren(...nodes) { this.__kids = []; this.__touch(); for (const n of nodes) this.appendChild(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n); }
162
162
  // bitmask: 1 DISCONNECTED, 2 PRECEDING, 4 FOLLOWING, 8 CONTAINS, 16 CONTAINED_BY
163
163
  compareDocumentPosition(other) {
164
164
  if (other === this) return 0;
@@ -189,9 +189,15 @@ export class Node extends EventTarget {
189
189
  }
190
190
  set textContent(value) {
191
191
  this.__kids = [];
192
+ this.__touch();
192
193
  if (value !== '') this.appendChild(this.ownerDocument.createTextNode(String(value)));
193
194
  }
194
195
 
196
+ // bump the document version so getElementsBy* caches invalidate after direct
197
+ // __kids reassignments (innerHTML/textContent/replaceChildren) that bypass
198
+ // insertBefore/removeChild.
199
+ __touch() { const d = this.ownerDocument; if (d) d.__version = (d.__version || 0) + 1; }
200
+
195
201
  // window/document path for event propagation past the document
196
202
  get __owner() { return null; }
197
203
  }
@@ -300,8 +306,8 @@ export class Element extends Node {
300
306
  get namespaceURI() { return nsUri(this.__ns); }
301
307
 
302
308
  // ---- attributes ----
303
- getAttribute(name) { const a = this.__attrs.find((x) => x.name === name); return a ? a.value : null; }
304
- hasAttribute(name) { return this.__attrs.some((x) => x.name === name); }
309
+ getAttribute(name) { const at = this.__attrs; for (let i = 0; i < at.length; i++) if (at[i].name === name) return at[i].value; return null; }
310
+ hasAttribute(name) { const at = this.__attrs; for (let i = 0; i < at.length; i++) if (at[i].name === name) return true; return false; }
305
311
  getAttributeNames() { return this.__attrs.map((a) => a.name); }
306
312
  setAttribute(name, value) {
307
313
  const a = this.__attrs.find((x) => x.name === name);
@@ -505,7 +511,7 @@ export class Element extends Node {
505
511
  before(...nodes) { const p = this.parentNode; if (!p) return; for (const n of nodes) p.insertBefore(toNode(this.ownerDocument, n), this); }
506
512
  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
513
  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)); }
514
+ replaceChildren(...nodes) { this.__kids = []; this.__touch(); for (const n of nodes) this.appendChild(toNode(this.ownerDocument, n)); }
509
515
 
510
516
  // ---- queries ----
511
517
  matches(sel) { return matchesSelector(this, sel); }
@@ -520,6 +526,7 @@ export class Element extends Node {
520
526
  set innerHTML(html) {
521
527
  const frag = native.parseFragment(String(html), this.__ns ? `${this.__ns} ${this.localName}` : this.localName);
522
528
  this.__kids = [];
529
+ this.__touch();
523
530
  for (const rawChild of frag.children) {
524
531
  if (rawChild.nodeType === DOCUMENT_FRAGMENT_NODE && rawChild.name === 'content') continue;
525
532
  const child = this.ownerDocument.__inflateNested(rawChild);
@@ -726,25 +733,43 @@ function toNode(doc, n) { return typeof n === 'string' ? doc.createTextNode(n) :
726
733
 
727
734
  function collectByTag(root, tag) {
728
735
  const out = [];
736
+ const all = tag === '*';
729
737
  const visit = (node) => {
730
- for (const c of node.__children()) {
731
- if (c.nodeType === ELEMENT_NODE) {
732
- if (tag === '*' || c.localName === tag) out.push(c);
733
- visit(c);
734
- }
738
+ const kids = node.__children();
739
+ for (let i = 0; i < kids.length; i++) {
740
+ const c = kids[i];
741
+ if (c.nodeType !== ELEMENT_NODE) continue;
742
+ if (all || c.localName === tag) out.push(c);
743
+ visit(c);
735
744
  }
736
745
  };
737
746
  visit(root);
738
747
  return out;
739
748
  }
749
+ // allocation-free whole-word class membership (no ClassList, no split)
750
+ function elHasClass(el, cls) {
751
+ const cn = el.getAttribute('class');
752
+ if (!cn) return false;
753
+ if (cn === cls) return true;
754
+ const L = cls.length;
755
+ let idx = cn.indexOf(cls);
756
+ while (idx !== -1) {
757
+ if ((idx === 0 || cn.charCodeAt(idx - 1) <= 32) && (idx + L === cn.length || cn.charCodeAt(idx + L) <= 32)) return true;
758
+ idx = cn.indexOf(cls, idx + 1);
759
+ }
760
+ return false;
761
+ }
740
762
  function collectByClass(root, classes) {
741
763
  const out = [];
742
764
  const visit = (node) => {
743
- for (const c of node.__children()) {
744
- if (c.nodeType === ELEMENT_NODE) {
745
- if (classes.every((cl) => c.classList.contains(cl))) out.push(c);
746
- visit(c);
747
- }
765
+ const kids = node.__children();
766
+ for (let i = 0; i < kids.length; i++) {
767
+ const c = kids[i];
768
+ if (c.nodeType !== ELEMENT_NODE) continue;
769
+ let ok = true;
770
+ for (let j = 0; j < classes.length; j++) if (!elHasClass(c, classes[j])) { ok = false; break; }
771
+ if (ok) out.push(c);
772
+ visit(c);
748
773
  }
749
774
  };
750
775
  visit(root);
@@ -938,7 +963,11 @@ function isDescendant(node, ancestor) {
938
963
  }
939
964
  function notifyMutation(target, record) {
940
965
  const doc = target.ownerDocument;
941
- if (!doc || !doc.__mo || doc.__mo.length === 0) return;
966
+ if (!doc) return;
967
+ // bump the DOM version on every structural/attribute mutation — invalidates
968
+ // the document's getElementsBy* caches. Unconditional (independent of observers).
969
+ doc.__version = (doc.__version || 0) + 1;
970
+ if (!doc.__mo || doc.__mo.length === 0) return;
942
971
  for (const reg of doc.__mo) {
943
972
  const { obs, target: obsTarget, options } = reg;
944
973
  const onTarget = record.target === obsTarget;
@@ -1091,6 +1120,9 @@ export class Document extends Node {
1091
1120
  this.__active = null;
1092
1121
  this.__mo = []; // drop observers
1093
1122
  this.__moPending = null;
1123
+ this.__version = (this.__version || 0) + 1; // invalidate getElementsBy* caches
1124
+ this.__tagCache = null;
1125
+ this.__classCache = null;
1094
1126
  }
1095
1127
 
1096
1128
  get documentElement() { return this.__children().find((n) => n.nodeType === ELEMENT_NODE && n.localName === 'html') ?? null; }
@@ -1173,8 +1205,28 @@ export class Document extends Node {
1173
1205
  }
1174
1206
  querySelector(sel) { return qsel(this, sel); }
1175
1207
  querySelectorAll(sel) { return qselAll(this, sel); }
1176
- getElementsByTagName(tag) { const self = this; return liveHTMLCollection(() => collectByTag(self, tag.toLowerCase())); }
1177
- getElementsByClassName(cls) { const self = this; const classes = cls.split(/\s+/).filter(Boolean); return liveHTMLCollection(() => collectByClass(self, classes)); }
1208
+ // version-keyed cache: getElementsBy* is called repeatedly within a single
1209
+ // query (e.g. RTL getByLabelText calls element.labels per element, each doing
1210
+ // document.getElementsByTagName('label')). Without caching that's O(n²) tree
1211
+ // walks. The cache invalidates whenever the DOM version bumps (any mutation).
1212
+ __byTag(t) {
1213
+ const v = this.__version || 0;
1214
+ const c = this.__tagCache && this.__tagCache.get(t);
1215
+ if (c && c.v === v) return c.arr;
1216
+ const arr = collectByTag(this, t);
1217
+ (this.__tagCache || (this.__tagCache = new Map())).set(t, { v, arr });
1218
+ return arr;
1219
+ }
1220
+ __byClass(key, classes) {
1221
+ const v = this.__version || 0;
1222
+ const c = this.__classCache && this.__classCache.get(key);
1223
+ if (c && c.v === v) return c.arr;
1224
+ const arr = collectByClass(this, classes);
1225
+ (this.__classCache || (this.__classCache = new Map())).set(key, { v, arr });
1226
+ return arr;
1227
+ }
1228
+ getElementsByTagName(tag) { const self = this; const t = tag.toLowerCase(); return liveHTMLCollection(() => self.__byTag(t)); }
1229
+ getElementsByClassName(cls) { const self = this; const classes = cls.split(/\s+/).filter(Boolean); return liveHTMLCollection(() => self.__byClass(cls, classes)); }
1178
1230
  contains(node) { return Node.prototype.contains.call(this, node); }
1179
1231
 
1180
1232
  // cookie jar: store name=value, strip attributes (path/Secure/SameSite/…),
@@ -83,8 +83,17 @@ function parseComplex(src) {
83
83
  return { compounds, combinators };
84
84
  }
85
85
 
86
+ // parsed-selector cache: querySelector(All)/matches re-run the same selector
87
+ // strings constantly; parsing once and reusing is a large win on query-heavy
88
+ // suites. Bounded so a pathological generator can't grow it without limit.
89
+ const __selectorCache = new Map();
86
90
  export function parseSelectorList(selector) {
87
- return splitTopLevel(selector, ',').map((s) => parseComplex(s.trim()));
91
+ const hit = __selectorCache.get(selector);
92
+ if (hit !== undefined) return hit;
93
+ const parsed = splitTopLevel(selector, ',').map((s) => parseComplex(s.trim()));
94
+ if (__selectorCache.size > 10000) __selectorCache.clear();
95
+ __selectorCache.set(selector, parsed);
96
+ return parsed;
88
97
  }
89
98
 
90
99
  function splitTopLevel(s, sep) {
@@ -106,9 +115,10 @@ function elementChildren(node) {
106
115
  }
107
116
 
108
117
  function matchAttr(el, a) {
109
- if (!el.hasAttribute(a.name)) return false;
118
+ const raw = el.getAttribute(a.name); // single lookup (null = absent)
119
+ if (raw === null) return false;
110
120
  if (a.op === null) return true;
111
- const v = el.getAttribute(a.name) ?? '';
121
+ const v = raw;
112
122
  const t = a.value ?? '';
113
123
  switch (a.op) {
114
124
  case '=': return v === t;
@@ -211,11 +221,32 @@ function nextElement(el) {
211
221
  return n || null;
212
222
  }
213
223
 
224
+ // allocation-free "does the class attribute contain this whole-word class"
225
+ function hasClass(cn, cls) {
226
+ if (cn === cls) return true;
227
+ const L = cls.length;
228
+ let idx = cn.indexOf(cls);
229
+ while (idx !== -1) {
230
+ const before = idx === 0 || cn.charCodeAt(idx - 1) <= 32;
231
+ const after = idx + L === cn.length || cn.charCodeAt(idx + L) <= 32;
232
+ if (before && after) return true;
233
+ idx = cn.indexOf(cls, idx + 1);
234
+ }
235
+ return false;
236
+ }
237
+
214
238
  function matchCompound(el, compound) {
215
239
  if (!el || el.nodeType !== 1) return false;
240
+ // cheapest checks first; tag/local is a plain property
216
241
  if (compound.tag && compound.tag !== '*' && el.localName !== compound.tag) return false;
217
242
  if (compound.id !== null && el.getAttribute('id') !== compound.id) return false;
218
- for (const cls of compound.classes) if (!el.classList.contains(cls)) return false;
243
+ // classes: read the class attribute ONCE and test membership without
244
+ // allocating (no ClassList, no split, no padded copy) — dominated matching.
245
+ if (compound.classes.length) {
246
+ const cn = el.getAttribute('class');
247
+ if (!cn) return false;
248
+ for (const cls of compound.classes) if (!hasClass(cn, cls)) return false;
249
+ }
219
250
  for (const a of compound.attrs) if (!matchAttr(el, a)) return false;
220
251
  for (const p of compound.pseudos) if (!matchPseudo(el, p)) return false;
221
252
  return true;
@@ -268,13 +299,43 @@ export function matchesSelector(el, selector) {
268
299
  return list.some((cx) => matchComplex(el, cx));
269
300
  }
270
301
 
302
+ // Fast paths for the overwhelmingly common simple selectors, skipping the
303
+ // parse + per-element matchComplex machinery.
304
+ const SIMPLE = /^\s*(#[\w-]+|\.[\w-]+|[a-zA-Z][\w-]*)\s*$/;
305
+ function simpleMatcher(selector) {
306
+ const m = SIMPLE.exec(selector);
307
+ if (!m) return null;
308
+ const s = m[1];
309
+ if (s[0] === '#') { const id = s.slice(1); return (el) => el.getAttribute('id') === id; }
310
+ if (s[0] === '.') { const cls = s.slice(1); return (el) => el.classList.contains(cls); }
311
+ const tag = s.toLowerCase(); return (el) => el.localName === tag;
312
+ }
313
+
314
+ // child node array without allocating a filtered copy per call
315
+ function rawChildren(node) {
316
+ return typeof node.__children === 'function' ? node.__children() : Array.from(node.childNodes || []);
317
+ }
318
+
271
319
  export function querySelectorAll(root, selector) {
272
- const list = parseSelectorList(selector);
320
+ const simple = simpleMatcher(selector);
273
321
  const out = [];
322
+ if (simple) {
323
+ const visit = (node) => {
324
+ const kids = rawChildren(node);
325
+ for (let i = 0; i < kids.length; i++) { const c = kids[i]; if (c.nodeType !== 1) continue; if (simple(c)) out.push(c); visit(c); }
326
+ };
327
+ visit(root);
328
+ return out;
329
+ }
330
+ const list = parseSelectorList(selector);
331
+ const single = list.length === 1 ? list[0] : null;
274
332
  const visit = (node) => {
275
- for (const child of elementChildren(node)) {
276
- if (list.some((cx) => matchComplex(child, cx))) out.push(child);
277
- visit(child);
333
+ const kids = rawChildren(node);
334
+ for (let i = 0; i < kids.length; i++) {
335
+ const c = kids[i];
336
+ if (c.nodeType !== 1) continue;
337
+ if (single ? matchComplex(c, single) : list.some((cx) => matchComplex(c, cx))) out.push(c);
338
+ visit(c);
278
339
  }
279
340
  };
280
341
  visit(root);
@@ -282,18 +343,29 @@ export function querySelectorAll(root, selector) {
282
343
  }
283
344
 
284
345
  export function querySelector(root, selector) {
346
+ const simple = simpleMatcher(selector);
347
+ if (simple) {
348
+ const visit = (node) => {
349
+ const kids = rawChildren(node);
350
+ for (let i = 0; i < kids.length; i++) { const c = kids[i]; if (c.nodeType !== 1) continue; if (simple(c)) return c; const r = visit(c); if (r) return r; }
351
+ return null;
352
+ };
353
+ return visit(root);
354
+ }
285
355
  const list = parseSelectorList(selector);
286
- let found = null;
356
+ const single = list.length === 1 ? list[0] : null;
287
357
  const visit = (node) => {
288
- for (const child of elementChildren(node)) {
289
- if (found) return;
290
- if (list.some((cx) => matchComplex(child, cx))) { found = child; return; }
291
- visit(child);
292
- if (found) return;
358
+ const kids = rawChildren(node);
359
+ for (let i = 0; i < kids.length; i++) {
360
+ const c = kids[i];
361
+ if (c.nodeType !== 1) continue;
362
+ if (single ? matchComplex(c, single) : list.some((cx) => matchComplex(c, cx))) return c;
363
+ const r = visit(c);
364
+ if (r) return r;
293
365
  }
366
+ return null;
294
367
  };
295
- visit(root);
296
- return found;
368
+ return visit(root);
297
369
  }
298
370
 
299
371
  export const _internal = { parseComplex, parseSelectorList, matchCompound, matchComplex };