@miaskiewicz/turbo-dom 0.1.20 → 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 CHANGED
@@ -105,13 +105,17 @@ the suite row (ms/file, lower = faster):
105
105
 
106
106
  | benchmark | turbo-dom | happy-dom | jsdom |
107
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 |
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
110
  | **parse 56 KB SSR** (ops/s) | **478** | 43 | 26 |
111
111
  | **parse 20 KB real page** (ops/s) | **4,203** | 190 | 114 |
112
- | repeated query throughput (iters/s) | **915k** | 615k | 3k |
112
+ | repeated query throughput (iters/s) | **920k** | 600k | 3k |
113
113
  | html5lib conformance | **99.72%** | 37.35% | 97.03% |
114
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
+
115
119
  **turbo-dom wins across the board on what test suites actually do**: per-file
116
120
  construction (~10× happy-dom, ~23× jsdom), parsing, realistic suites (~10× happy-dom,
117
121
  ~23× jsdom), spec-correctness (99.7% vs 37%), **and** repeated queries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.1.20",
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",
@@ -98,8 +98,12 @@ export class Node extends EventTarget {
98
98
  }
99
99
 
100
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;
101
105
  const self = this;
102
- return liveNodeList(() => self.__children());
106
+ return (this.__childNodesList = liveNodeList(() => self.__children()));
103
107
  }
104
108
  get firstChild() { const k = this.__children(); return k[0] ?? null; }
105
109
  get lastChild() { const k = this.__children(); return k[k.length - 1] ?? null; }
@@ -523,8 +527,9 @@ export class Element extends Node {
523
527
 
524
528
  // ---- element-only traversal (live) ----
525
529
  get children() {
530
+ if (this.__childrenList) return this.__childrenList;
526
531
  const self = this;
527
- return liveHTMLCollection(() => self.__children().filter((n) => n.nodeType === ELEMENT_NODE));
532
+ return (this.__childrenList = liveHTMLCollection(() => self.__children().filter((n) => n.nodeType === ELEMENT_NODE)));
528
533
  }
529
534
  get childElementCount() { return this.__children().filter((n) => n.nodeType === ELEMENT_NODE).length; }
530
535
  get firstElementChild() { return this.__children().find((n) => n.nodeType === ELEMENT_NODE) ?? null; }
@@ -750,7 +755,7 @@ export class DocumentFragment extends Node {
750
755
  get nodeName() { return '#document-fragment'; }
751
756
  querySelector(sel) { return cachedQS(this, sel); }
752
757
  querySelectorAll(sel) { return cachedQSA(this, sel); }
753
- get children() { const self = this; return liveHTMLCollection(() => self.__children().filter((n) => n.nodeType === ELEMENT_NODE)); }
758
+ get children() { if (this.__childrenList) return this.__childrenList; const self = this; return (this.__childrenList = liveHTMLCollection(() => self.__children().filter((n) => n.nodeType === ELEMENT_NODE))); }
754
759
  append(...nodes) { for (const n of nodes) this.appendChild(toNode(this.ownerDocument, n)); }
755
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; }
756
761
  }
@@ -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
- event._path = this.__eventPath();
154
- const path = event._path;
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
- // capturing: root -> just before target
189
- for (let i = path.length - 1; i >= 1; i--) {
190
- if (event._stopPropagation) break;
191
- invoke(path[i], PHASE_CAPTURING);
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], PHASE_BUBBLING);
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
 
@@ -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
- return new Proxy({}, {
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
 
@@ -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: makeURL(), URLSearchParams,
121
- Blob: globalThis.Blob, File: makeFile(), FileReader,
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.