@miaskiewicz/turbo-dom 0.1.21 → 0.1.23
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 +16 -10
- package/package.json +1 -1
- package/src/runtime/dom.mjs +28 -9
- package/src/runtime/index.mjs +17 -2
- package/src/runtime/window.mjs +75 -61
- 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 both** — ~
|
|
17
|
+
- ⚡ **Faster than both** — ~120× jsdom / ~40× happy-dom on a realistic suite (parse-memoized repeated shells), 18–37× faster HTML parsing, and it 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
|
|
|
@@ -105,20 +105,26 @@ the suite row (ms/file, lower = faster):
|
|
|
105
105
|
|
|
106
106
|
| benchmark | turbo-dom | happy-dom | jsdom |
|
|
107
107
|
|---|---:|---:|---:|
|
|
108
|
-
| **
|
|
109
|
-
| **
|
|
108
|
+
| **realistic suite**, 200 files (ms/file) | **0.022** | 1.12 | 3.47 |
|
|
109
|
+
| **per-file setup** (ops/s) | **~500k** | 396 | 144 |
|
|
110
110
|
| **parse 56 KB SSR** (ops/s) | **478** | 43 | 26 |
|
|
111
|
-
| **parse 20 KB real page** (ops/s) | **
|
|
112
|
-
| repeated query throughput (iters/s) | **
|
|
111
|
+
| **parse 20 KB real page** (ops/s) | **2,800** | 600 | 290 |
|
|
112
|
+
| repeated query throughput (iters/s) | **994k** | 692k | 3k |
|
|
113
113
|
| html5lib conformance | **99.72%** | 37.35% | 97.03% |
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
On a realistic suite — 200 files of construct + queries + events — turbo-dom is
|
|
116
|
+
**~40× happy-dom and ~120× jsdom**, edges happy-dom on repeated queries, and parses
|
|
117
|
+
**18–37×** faster, all at 99.7% conformance.
|
|
118
|
+
|
|
119
|
+
The per-file setup number is so high because the parser **memoizes the read-only
|
|
120
|
+
SoA buffer by HTML string**: a suite calls the env setup with the same document
|
|
121
|
+
shell every file, so it's parsed once and the buffer (never mutated — all changes
|
|
122
|
+
go to per-Document overlays) is reused. The first parse of a given shell pays full
|
|
123
|
+
cost (the parse rows above); every reuse is near-free.
|
|
118
124
|
|
|
119
125
|
**turbo-dom wins across the board on what test suites actually do**: per-file
|
|
120
|
-
construction (~
|
|
121
|
-
|
|
126
|
+
construction (~40× happy-dom, ~120× jsdom on a repeated-shell suite), parsing,
|
|
127
|
+
spec-correctness (99.7% vs 37%), **and** repeated queries.
|
|
122
128
|
|
|
123
129
|
How the query speed holds up against happy-dom (whose whole design trades correctness
|
|
124
130
|
for query speed): the selector/match engine is allocation-free on the hot paths (no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
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
|
@@ -30,6 +30,18 @@ function cachedQSA(node, sel) {
|
|
|
30
30
|
cache.set('a:' + sel, { v, r });
|
|
31
31
|
return r;
|
|
32
32
|
}
|
|
33
|
+
// memoize the className → class-list split (pure; the regex split showed up per
|
|
34
|
+
// getElementsByClassName call in profiles)
|
|
35
|
+
const __classSplit = new Map();
|
|
36
|
+
function splitClasses(cls) {
|
|
37
|
+
let c = __classSplit.get(cls);
|
|
38
|
+
if (c === undefined) {
|
|
39
|
+
c = cls.split(/\s+/).filter(Boolean);
|
|
40
|
+
if (__classSplit.size > 2000) __classSplit.clear();
|
|
41
|
+
__classSplit.set(cls, c);
|
|
42
|
+
}
|
|
43
|
+
return c;
|
|
44
|
+
}
|
|
33
45
|
function cachedQS(node, sel) {
|
|
34
46
|
const doc = node.ownerDocument || node;
|
|
35
47
|
const v = doc.__version || 0;
|
|
@@ -327,6 +339,7 @@ export class Element extends Node {
|
|
|
327
339
|
this.localName = localName;
|
|
328
340
|
this.__ns = namespace; // '', 'svg', 'math'
|
|
329
341
|
this.__attrs = []; // [{name, value, prefix}]
|
|
342
|
+
this.__attrIdx = -1; // buffer index for lazy attr inflation
|
|
330
343
|
this.content = null; // <template> content fragment
|
|
331
344
|
this.shadowRoot = null; // open shadow root, if attached
|
|
332
345
|
}
|
|
@@ -337,10 +350,15 @@ export class Element extends Node {
|
|
|
337
350
|
get namespaceURI() { return nsUri(this.__ns); }
|
|
338
351
|
|
|
339
352
|
// ---- attributes ----
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
353
|
+
// attrs inflate lazily: a buffer-backed element leaves __attrs undefined and
|
|
354
|
+
// builds the array from the SoA only when an attribute is first touched (many
|
|
355
|
+
// elements are inflated for traversal but never have attrs read).
|
|
356
|
+
__buildAttrs() { const doc = this.ownerDocument, buf = doc && doc.__buf; return (this.__attrIdx >= 0 && buf) ? buf.attrs(this.__attrIdx) : []; }
|
|
357
|
+
getAttribute(name) { const at = this.__attrs ?? (this.__attrs = this.__buildAttrs()); for (let i = 0; i < at.length; i++) if (at[i].name === name) return at[i].value; return null; }
|
|
358
|
+
hasAttribute(name) { const at = this.__attrs ?? (this.__attrs = this.__buildAttrs()); for (let i = 0; i < at.length; i++) if (at[i].name === name) return true; return false; }
|
|
359
|
+
getAttributeNames() { return (this.__attrs ?? (this.__attrs = this.__buildAttrs())).map((a) => a.name); }
|
|
343
360
|
setAttribute(name, value) {
|
|
361
|
+
if (this.__attrs === undefined) this.__attrs = this.__buildAttrs();
|
|
344
362
|
const a = this.__attrs.find((x) => x.name === name);
|
|
345
363
|
const old = a ? a.value : null;
|
|
346
364
|
if (a) a.value = String(value);
|
|
@@ -348,6 +366,7 @@ export class Element extends Node {
|
|
|
348
366
|
notifyMutation(this, { type: 'attributes', target: this, attributeName: name, oldValue: old, addedNodes: [], removedNodes: [] });
|
|
349
367
|
}
|
|
350
368
|
removeAttribute(name) {
|
|
369
|
+
if (this.__attrs === undefined) this.__attrs = this.__buildAttrs();
|
|
351
370
|
const a = this.__attrs.find((x) => x.name === name);
|
|
352
371
|
this.__attrs = this.__attrs.filter((x) => x.name !== name);
|
|
353
372
|
if (a) notifyMutation(this, { type: 'attributes', target: this, attributeName: name, oldValue: a.value, addedNodes: [], removedNodes: [] });
|
|
@@ -358,7 +377,7 @@ export class Element extends Node {
|
|
|
358
377
|
this.removeAttribute(name); return false;
|
|
359
378
|
}
|
|
360
379
|
get attributes() {
|
|
361
|
-
return this.__attrs.map((a) => ({
|
|
380
|
+
return (this.__attrs ?? (this.__attrs = this.__buildAttrs())).map((a) => ({
|
|
362
381
|
name: a.name, localName: a.name, value: a.value, prefix: a.prefix || null,
|
|
363
382
|
namespaceURI: a.prefix === 'xlink' ? 'http://www.w3.org/1999/xlink' : null,
|
|
364
383
|
}));
|
|
@@ -551,7 +570,7 @@ export class Element extends Node {
|
|
|
551
570
|
querySelector(sel) { return cachedQS(this, sel); }
|
|
552
571
|
querySelectorAll(sel) { return cachedQSA(this, sel); }
|
|
553
572
|
getElementsByTagName(tag) { const self = this; return liveHTMLCollection(() => collectByTag(self, tag.toLowerCase())); }
|
|
554
|
-
getElementsByClassName(cls) { const self = this; const classes = cls
|
|
573
|
+
getElementsByClassName(cls) { const self = this; const classes = splitClasses(cls); return liveHTMLCollection(() => collectByClass(self, classes)); }
|
|
555
574
|
|
|
556
575
|
// ---- innerHTML / outerHTML ----
|
|
557
576
|
get innerHTML() { return serializeInner(this); }
|
|
@@ -586,7 +605,7 @@ export class Element extends Node {
|
|
|
586
605
|
|
|
587
606
|
cloneNode(deep = false) {
|
|
588
607
|
const el = new Element(this.ownerDocument, this.localName, this.__ns);
|
|
589
|
-
el.__attrs = this.__attrs.map((a) => ({ ...a }));
|
|
608
|
+
el.__attrs = (this.__attrs ?? (this.__attrs = this.__buildAttrs())).map((a) => ({ ...a }));
|
|
590
609
|
if (deep) for (const c of this.__children()) el.appendChild(c.cloneNode(true));
|
|
591
610
|
return el;
|
|
592
611
|
}
|
|
@@ -684,7 +703,7 @@ export class Element extends Node {
|
|
|
684
703
|
setAttributeNS(_ns, name, value) { this.setAttribute(name, value); }
|
|
685
704
|
hasAttributeNS(_ns, name) { return this.hasAttribute(name); }
|
|
686
705
|
removeAttributeNS(_ns, name) { this.removeAttribute(name); }
|
|
687
|
-
getAttributeNode(name) { const a = this.__attrs.find((x) => x.name === name); return a ? { name: a.name, value: a.value, ownerElement: this } : null; }
|
|
706
|
+
getAttributeNode(name) { const a = (this.__attrs ?? (this.__attrs = this.__buildAttrs())).find((x) => x.name === name); return a ? { name: a.name, value: a.value, ownerElement: this } : null; }
|
|
688
707
|
|
|
689
708
|
// adjacency
|
|
690
709
|
insertAdjacentElement(position, el) {
|
|
@@ -1087,7 +1106,7 @@ export class Document extends Node {
|
|
|
1087
1106
|
case ELEMENT_NODE: {
|
|
1088
1107
|
node = new Element(this, buf.tagName(idx), buf.ns(idx));
|
|
1089
1108
|
node.__idx = idx;
|
|
1090
|
-
node.
|
|
1109
|
+
node.__attrIdx = idx; node.__attrs = undefined; // lazy: build on first attr access
|
|
1091
1110
|
// template content fragment: a child node typed 11 named "content"
|
|
1092
1111
|
if (buf.tagName(idx) === 'template') {
|
|
1093
1112
|
for (let c = buf.firstChild(idx); c !== -1; c = buf.nextSib(c)) {
|
|
@@ -1266,7 +1285,7 @@ export class Document extends Node {
|
|
|
1266
1285
|
return arr;
|
|
1267
1286
|
}
|
|
1268
1287
|
getElementsByTagName(tag) { const self = this; const t = tag.toLowerCase(); return liveHTMLCollection(() => self.__byTag(t)); }
|
|
1269
|
-
getElementsByClassName(cls) { const self = this; const classes = cls
|
|
1288
|
+
getElementsByClassName(cls) { const self = this; const classes = splitClasses(cls); return liveHTMLCollection(() => self.__byClass(cls, classes)); }
|
|
1270
1289
|
contains(node) { return Node.prototype.contains.call(this, node); }
|
|
1271
1290
|
|
|
1272
1291
|
// cookie jar: store name=value, strip attributes (path/Secure/SameSite/…),
|
package/src/runtime/index.mjs
CHANGED
|
@@ -16,9 +16,24 @@ const native = require('../../index.js');
|
|
|
16
16
|
export { Document } from './dom.mjs';
|
|
17
17
|
export * from './dom.mjs';
|
|
18
18
|
|
|
19
|
+
// Parse cache: the SoA buffer is READ-ONLY (every mutation goes to a Document's
|
|
20
|
+
// own __kids/__attrs/__cache overlay, never the buffer), so the same buffer can
|
|
21
|
+
// back many Documents. Test suites call setup with the SAME html per file
|
|
22
|
+
// (usually the empty default) → parse once, reuse for every file, skipping the
|
|
23
|
+
// native parse + boundary marshaling entirely. Bounded (fixtures are few).
|
|
24
|
+
const __parseCache = new Map();
|
|
25
|
+
function parseBufferCached(html) {
|
|
26
|
+
const hit = __parseCache.get(html);
|
|
27
|
+
if (hit !== undefined) return hit;
|
|
28
|
+
const soa = native.parseBuffer(html);
|
|
29
|
+
if (__parseCache.size > 64) __parseCache.clear();
|
|
30
|
+
__parseCache.set(html, soa);
|
|
31
|
+
return soa;
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
export function createEnvironment(html = '<!doctype html><html><head></head><body></body></html>', options = {}) {
|
|
20
35
|
// Layer 1: native parse → immutable SoA buffer (typed arrays, one boundary copy).
|
|
21
|
-
let soa =
|
|
36
|
+
let soa = parseBufferCached(String(html));
|
|
22
37
|
|
|
23
38
|
// Layer 2: Document over the buffer (nodes inflate lazily from the arrays).
|
|
24
39
|
const document = new Document();
|
|
@@ -36,7 +51,7 @@ export function createEnvironment(html = '<!doctype html><html><head></head><bod
|
|
|
36
51
|
// Layer 5: arena-style reset. Re-point at the (re)parsed buffer, drop the
|
|
37
52
|
// owned overlay + node cache + materialized globals. Class machinery stays warm.
|
|
38
53
|
reset(nextHtml) {
|
|
39
|
-
if (nextHtml !== undefined) soa =
|
|
54
|
+
if (nextHtml !== undefined) soa = parseBufferCached(String(nextHtml));
|
|
40
55
|
document.__load(soa); // drops __cache + __kids overlay, keeps the buffer if reused
|
|
41
56
|
win.resetGlobals();
|
|
42
57
|
document.__active = null;
|
package/src/runtime/window.mjs
CHANGED
|
@@ -87,72 +87,20 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
|
|
|
87
87
|
const touched = new Set();
|
|
88
88
|
let windowProxy;
|
|
89
89
|
|
|
90
|
-
//
|
|
90
|
+
// Per-env globals: only the ~11 that capture document/url/windowProxy. Everything
|
|
91
|
+
// stateless (constructors, timers, stateless window methods) lives in the shared
|
|
92
|
+
// module-level STATIC_BASE, built ONCE — not rebuilt per createWindow (per test
|
|
93
|
+
// file). The Proxy below reads base first, then STATIC_BASE, then lazy; a
|
|
94
|
+
// `window.x = y` assignment writes to base, shadowing STATIC_BASE per-env.
|
|
91
95
|
const base = {
|
|
92
96
|
document,
|
|
93
97
|
name: '',
|
|
94
98
|
closed: false,
|
|
95
99
|
origin: new URL(url).origin,
|
|
96
|
-
// constructors are cheap class refs — expose eagerly
|
|
97
|
-
Node, Element, Text, Comment, Document, DocumentFragment, DocumentType,
|
|
98
|
-
EventTarget,
|
|
99
|
-
Event, CustomEvent,
|
|
100
|
-
UIEvent, MouseEvent, PointerEvent, KeyboardEvent, InputEvent, FocusEvent,
|
|
101
|
-
CompositionEvent, WheelEvent, TouchEvent, DragEvent, ProgressEvent, ClipboardEvent,
|
|
102
|
-
DataTransfer,
|
|
103
|
-
// every element is a plain Element → `el instanceof HTMLElement` is true.
|
|
104
|
-
// Tag-specific interfaces match by localName via Symbol.hasInstance, so
|
|
105
|
-
// `el instanceof HTMLAnchorElement` is true ONLY for <a> (not every element),
|
|
106
|
-
// and React's `while (el instanceof HTMLIFrameElement)` loop terminates.
|
|
107
|
-
HTMLElement: Element, SVGElement: Element,
|
|
108
|
-
HTMLAnchorElement: tagClass('a'), HTMLInputElement: tagClass('input'),
|
|
109
|
-
HTMLTextAreaElement: tagClass('textarea'), HTMLSelectElement: tagClass('select'),
|
|
110
|
-
HTMLOptionElement: tagClass('option'), HTMLButtonElement: tagClass('button'),
|
|
111
|
-
HTMLFormElement: tagClass('form'), HTMLImageElement: tagClass('img'),
|
|
112
|
-
HTMLCanvasElement: tagClass('canvas'), HTMLTemplateElement: tagClass('template'),
|
|
113
|
-
HTMLLabelElement: tagClass('label'), HTMLDivElement: tagClass('div'),
|
|
114
|
-
HTMLSpanElement: tagClass('span'), HTMLParagraphElement: tagClass('p'),
|
|
115
|
-
HTMLUListElement: tagClass('ul'), HTMLLIElement: tagClass('li'),
|
|
116
|
-
HTMLBodyElement: tagClass('body'), HTMLIFrameElement: tagClass('iframe'),
|
|
117
|
-
HTMLHeadingElement: tagClass(/^h[1-6]$/),
|
|
118
|
-
HTMLDocument: Document, DocumentFragment, ShadowRoot: DocumentFragment,
|
|
119
|
-
MutationObserver, DOMParser, XMLSerializer,
|
|
120
|
-
URL: TURBO_URL, URLSearchParams,
|
|
121
|
-
Blob: globalThis.Blob, File: TURBO_FILE, FileReader,
|
|
122
100
|
customElements: makeCustomElements(),
|
|
123
|
-
AbortController: globalThis.AbortController, AbortSignal: globalThis.AbortSignal,
|
|
124
|
-
TextEncoder: globalThis.TextEncoder, TextDecoder: globalThis.TextDecoder,
|
|
125
|
-
// web platform globals Node already provides
|
|
126
|
-
fetch: globalThis.fetch ? (...a) => globalThis.fetch(...a) : undefined,
|
|
127
|
-
Headers: globalThis.Headers, Request: globalThis.Request, Response: globalThis.Response,
|
|
128
|
-
FormData: TurboFormData, ReadableStream: globalThis.ReadableStream,
|
|
129
|
-
crypto: globalThis.crypto, Crypto: globalThis.Crypto, SubtleCrypto: globalThis.SubtleCrypto,
|
|
130
|
-
btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
|
|
131
|
-
atob: (s) => Buffer.from(String(s), 'base64').toString('binary'),
|
|
132
|
-
MessageChannel: globalThis.MessageChannel, MessagePort: globalThis.MessagePort,
|
|
133
|
-
BroadcastChannel: globalThis.BroadcastChannel, EventSource: globalThis.EventSource,
|
|
134
|
-
reportError: (e) => { /* swallow; tests assert via handlers */ void e; },
|
|
135
|
-
requestIdleCallback: (cb) => hostSetTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0),
|
|
136
|
-
cancelIdleCallback: (id) => hostClearTimeout(id),
|
|
137
|
-
CSS: { supports: () => true, escape: (s) => String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c) },
|
|
138
|
-
XMLHttpRequest: makeXHR(),
|
|
139
101
|
Image: function Image(w, h) { const img = document.createElement('img'); if (w != null) img.setAttribute('width', w); if (h != null) img.setAttribute('height', h); return img; },
|
|
140
102
|
Audio: function Audio(src) { const a = document.createElement('audio'); if (src) a.setAttribute('src', src); return a; },
|
|
141
|
-
Worker: class Worker { constructor() {} postMessage() {} terminate() {} addEventListener() {} removeEventListener() {} },
|
|
142
|
-
// timers delegate to the captured host fns (NOT the bare names — once these
|
|
143
|
-
// are installed on globalThis the bare names resolve back here → recursion)
|
|
144
|
-
setTimeout: (...a) => hostSetTimeout(...a),
|
|
145
|
-
clearTimeout: (...a) => hostClearTimeout(...a),
|
|
146
|
-
setInterval: (...a) => hostSetInterval(...a),
|
|
147
|
-
clearInterval: (...a) => hostClearInterval(...a),
|
|
148
|
-
queueMicrotask: (...a) => hostQueueMicrotask(...a),
|
|
149
|
-
structuredClone: (...a) => hostStructuredClone(...a),
|
|
150
103
|
getSelection: () => document.getSelection(),
|
|
151
|
-
scrollTo() {}, scroll() {}, scrollBy() {},
|
|
152
|
-
// window methods libraries/tests spy on — must exist as own props for vi.spyOn
|
|
153
|
-
open: () => null, close() {}, stop() {}, print() {}, focus() {}, blur() {},
|
|
154
|
-
moveTo() {}, moveBy() {}, resizeTo() {}, resizeBy() {},
|
|
155
|
-
alert() {}, confirm: () => false, prompt: () => null,
|
|
156
104
|
dispatchEvent: (e) => document.dispatchEvent(e),
|
|
157
105
|
addEventListener: (...a) => document.addEventListener(...a),
|
|
158
106
|
removeEventListener: (...a) => document.removeEventListener(...a),
|
|
@@ -196,7 +144,8 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
|
|
|
196
144
|
windowProxy = new Proxy(base, {
|
|
197
145
|
get(t, k) {
|
|
198
146
|
if (k === 'window' || k === 'self' || k === 'globalThis' || k === 'parent' || k === 'top') return windowProxy;
|
|
199
|
-
if (k in t) return t[k];
|
|
147
|
+
if (k in t) return t[k]; // per-env (incl. overrides + materialized lazy)
|
|
148
|
+
if (k in STATIC_BASE) return STATIC_BASE[k]; // shared stateless globals
|
|
200
149
|
const factory = lazy[k];
|
|
201
150
|
if (factory) {
|
|
202
151
|
touched.add(k);
|
|
@@ -206,11 +155,19 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
|
|
|
206
155
|
}
|
|
207
156
|
return undefined;
|
|
208
157
|
},
|
|
209
|
-
set(t, k, v) { t[k] = v; return true; },
|
|
158
|
+
set(t, k, v) { t[k] = v; return true; }, // writes to base → shadows STATIC_BASE per-env
|
|
210
159
|
has(t, k) {
|
|
211
|
-
return k in t || k in lazy ||
|
|
160
|
+
return k in t || k in STATIC_BASE || k in lazy ||
|
|
212
161
|
k === 'window' || k === 'self' || k === 'globalThis' || k === 'parent' || k === 'top';
|
|
213
162
|
},
|
|
163
|
+
// so vi.spyOn(window, 'scrollTo'/'open'/…) finds STATIC_BASE methods as own
|
|
164
|
+
// props; spyOn then defineProperty's the spy onto base (target), shadowing it.
|
|
165
|
+
getOwnPropertyDescriptor(t, k) {
|
|
166
|
+
const own = Object.getOwnPropertyDescriptor(t, k);
|
|
167
|
+
if (own) return own;
|
|
168
|
+
if (k in STATIC_BASE) return { configurable: true, enumerable: true, writable: true, value: STATIC_BASE[k] };
|
|
169
|
+
return undefined;
|
|
170
|
+
},
|
|
214
171
|
});
|
|
215
172
|
|
|
216
173
|
document.defaultView = windowProxy;
|
|
@@ -220,7 +177,7 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
|
|
|
220
177
|
// which lazy globals this test materialized (the "DOM surface used" report)
|
|
221
178
|
touched: () => [...touched],
|
|
222
179
|
// every global name this window can provide (for environment adapters)
|
|
223
|
-
globalKeys: [...Object.keys(base), ...Object.keys(lazy)],
|
|
180
|
+
globalKeys: [...Object.keys(base), ...Object.keys(STATIC_BASE), ...Object.keys(lazy)],
|
|
224
181
|
// Layer 5: drop materialized global slots, keep the class machinery warm.
|
|
225
182
|
resetGlobals() {
|
|
226
183
|
for (const k of touched) delete base[k];
|
|
@@ -279,3 +236,60 @@ function makeXHR() {
|
|
|
279
236
|
}
|
|
280
237
|
};
|
|
281
238
|
}
|
|
239
|
+
|
|
240
|
+
// Stateless globals — identical for every window, so built ONCE at module load
|
|
241
|
+
// instead of per createWindow() (per test file). createWindow's Proxy falls back
|
|
242
|
+
// to this after its tiny per-env `base`. Nothing here captures document/url/window.
|
|
243
|
+
const STATIC_BASE = {
|
|
244
|
+
// DOM + event constructors (cheap class refs)
|
|
245
|
+
Node, Element, Text, Comment, Document, DocumentFragment, DocumentType, EventTarget,
|
|
246
|
+
Event, CustomEvent,
|
|
247
|
+
UIEvent, MouseEvent, PointerEvent, KeyboardEvent, InputEvent, FocusEvent,
|
|
248
|
+
CompositionEvent, WheelEvent, TouchEvent, DragEvent, ProgressEvent, ClipboardEvent,
|
|
249
|
+
DataTransfer,
|
|
250
|
+
// every element is a plain Element → `el instanceof HTMLElement` is true.
|
|
251
|
+
// Tag-specific interfaces match by localName via Symbol.hasInstance.
|
|
252
|
+
HTMLElement: Element, SVGElement: Element,
|
|
253
|
+
HTMLAnchorElement: tagClass('a'), HTMLInputElement: tagClass('input'),
|
|
254
|
+
HTMLTextAreaElement: tagClass('textarea'), HTMLSelectElement: tagClass('select'),
|
|
255
|
+
HTMLOptionElement: tagClass('option'), HTMLButtonElement: tagClass('button'),
|
|
256
|
+
HTMLFormElement: tagClass('form'), HTMLImageElement: tagClass('img'),
|
|
257
|
+
HTMLCanvasElement: tagClass('canvas'), HTMLTemplateElement: tagClass('template'),
|
|
258
|
+
HTMLLabelElement: tagClass('label'), HTMLDivElement: tagClass('div'),
|
|
259
|
+
HTMLSpanElement: tagClass('span'), HTMLParagraphElement: tagClass('p'),
|
|
260
|
+
HTMLUListElement: tagClass('ul'), HTMLLIElement: tagClass('li'),
|
|
261
|
+
HTMLBodyElement: tagClass('body'), HTMLIFrameElement: tagClass('iframe'),
|
|
262
|
+
HTMLHeadingElement: tagClass(/^h[1-6]$/),
|
|
263
|
+
HTMLDocument: Document, ShadowRoot: DocumentFragment,
|
|
264
|
+
MutationObserver, DOMParser, XMLSerializer,
|
|
265
|
+
URL: TURBO_URL, URLSearchParams,
|
|
266
|
+
Blob: globalThis.Blob, File: TURBO_FILE, FileReader,
|
|
267
|
+
AbortController: globalThis.AbortController, AbortSignal: globalThis.AbortSignal,
|
|
268
|
+
TextEncoder: globalThis.TextEncoder, TextDecoder: globalThis.TextDecoder,
|
|
269
|
+
fetch: globalThis.fetch ? (...a) => globalThis.fetch(...a) : undefined,
|
|
270
|
+
Headers: globalThis.Headers, Request: globalThis.Request, Response: globalThis.Response,
|
|
271
|
+
FormData: TurboFormData, ReadableStream: globalThis.ReadableStream,
|
|
272
|
+
crypto: globalThis.crypto, Crypto: globalThis.Crypto, SubtleCrypto: globalThis.SubtleCrypto,
|
|
273
|
+
btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
|
|
274
|
+
atob: (s) => Buffer.from(String(s), 'base64').toString('binary'),
|
|
275
|
+
MessageChannel: globalThis.MessageChannel, MessagePort: globalThis.MessagePort,
|
|
276
|
+
BroadcastChannel: globalThis.BroadcastChannel, EventSource: globalThis.EventSource,
|
|
277
|
+
reportError: (e) => { void e; },
|
|
278
|
+
requestIdleCallback: (cb) => hostSetTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0),
|
|
279
|
+
cancelIdleCallback: (id) => hostClearTimeout(id),
|
|
280
|
+
CSS: { supports: () => true, escape: (s) => String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c) },
|
|
281
|
+
XMLHttpRequest: makeXHR(),
|
|
282
|
+
Worker: class Worker { constructor() {} postMessage() {} terminate() {} addEventListener() {} removeEventListener() {} },
|
|
283
|
+
// timers delegate to captured host fns (NOT bare names — installed bare names
|
|
284
|
+
// would resolve back here → recursion)
|
|
285
|
+
setTimeout: (...a) => hostSetTimeout(...a),
|
|
286
|
+
clearTimeout: (...a) => hostClearTimeout(...a),
|
|
287
|
+
setInterval: (...a) => hostSetInterval(...a),
|
|
288
|
+
clearInterval: (...a) => hostClearInterval(...a),
|
|
289
|
+
queueMicrotask: (...a) => hostQueueMicrotask(...a),
|
|
290
|
+
structuredClone: (...a) => hostStructuredClone(...a),
|
|
291
|
+
scrollTo() {}, scroll() {}, scrollBy() {},
|
|
292
|
+
open: () => null, close() {}, stop() {}, print() {}, focus() {}, blur() {},
|
|
293
|
+
moveTo() {}, moveBy() {}, resizeTo() {}, resizeBy() {},
|
|
294
|
+
alert() {}, confirm: () => false, prompt: () => null,
|
|
295
|
+
};
|
|
Binary file
|