@miaskiewicz/turbo-dom 0.2.2 → 0.2.4
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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +2 -2
- package/pkg/turbo_dom_parser_bg.wasm +0 -0
- package/pkg-web/turbo_dom_parser_bg.wasm +0 -0
- package/src/runtime/dom.mjs +54 -6
- package/src/runtime/index.mjs +4 -0
- package/src/runtime/stubs.mjs +62 -9
- package/src/runtime/window.mjs +54 -5
- package/turbo-dom-parser.darwin-arm64.node +0 -0
- package/turbo-dom-parser.linux-arm64-gnu.node +0 -0
- package/turbo-dom-parser.linux-x64-gnu.node +0 -0
- package/turbo-dom-parser.linux-x64-musl.node +0 -0
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"build:wasm:pkg": "wasm-pack build --target nodejs --out-dir pkg --no-default-features --features wasm-bind",
|
|
76
76
|
"build:wasm:web": "wasm-pack build --target web --out-dir pkg-web --no-default-features --features wasm-bind",
|
|
77
77
|
"test": "node --test 'test/*.mjs'",
|
|
78
|
-
"test:cov": "node --test --experimental-test-coverage --test-coverage-include='src/runtime/**' --test-coverage-lines=99 --test-coverage-functions=
|
|
78
|
+
"test:cov": "node --test --experimental-test-coverage --test-coverage-include='src/runtime/**' --test-coverage-lines=99 --test-coverage-functions=95 --test-coverage-branches=95 'test/*.mjs'",
|
|
79
79
|
"test:rust": "cargo test --lib",
|
|
80
80
|
"test:all": "npm run build && npm run test:rust && npm test",
|
|
81
81
|
"prepare": "git config core.hooksPath .githooks || true",
|
|
Binary file
|
|
Binary file
|
package/src/runtime/dom.mjs
CHANGED
|
@@ -826,17 +826,17 @@ export class Element extends Node {
|
|
|
826
826
|
this.dispatchEvent(new FocusEvent('blur'));
|
|
827
827
|
this.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
|
828
828
|
}
|
|
829
|
-
getBoundingClientRect() { return
|
|
830
|
-
getClientRects() { return []; }
|
|
829
|
+
getBoundingClientRect() { const w = synthWidth(this), h = synthHeight(this); return { x: 0, y: 0, top: 0, left: 0, right: w, bottom: h, width: w, height: h, toJSON() { return this; } }; }
|
|
830
|
+
getClientRects() { const r = this.getBoundingClientRect(); return (r.width || r.height) ? [r] : []; }
|
|
831
831
|
scrollIntoView() {}
|
|
832
832
|
scroll() {} scrollTo() {} scrollBy() {}
|
|
833
|
-
//
|
|
834
|
-
get offsetWidth() { return
|
|
833
|
+
// synthetic box model (no real layout) — see synthWidth/synthHeight below
|
|
834
|
+
get offsetWidth() { return synthWidth(this); } get offsetHeight() { return synthHeight(this); }
|
|
835
835
|
get offsetTop() { return 0; } get offsetLeft() { return 0; }
|
|
836
836
|
get offsetParent() { return null; }
|
|
837
|
-
get clientWidth() { return
|
|
837
|
+
get clientWidth() { return synthWidth(this); } get clientHeight() { return synthHeight(this); }
|
|
838
838
|
get clientTop() { return 0; } get clientLeft() { return 0; }
|
|
839
|
-
get scrollWidth() { return
|
|
839
|
+
get scrollWidth() { return synthWidth(this); } get scrollHeight() { return synthHeight(this); }
|
|
840
840
|
get scrollTop() { return 0; } set scrollTop(_v) {} get scrollLeft() { return 0; } set scrollLeft(_v) {}
|
|
841
841
|
|
|
842
842
|
// namespaced attributes
|
|
@@ -1140,6 +1140,54 @@ function zeroRect() {
|
|
|
1140
1140
|
return { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() { return this; } };
|
|
1141
1141
|
}
|
|
1142
1142
|
|
|
1143
|
+
// ---- synthetic geometry --------------------------------------------------
|
|
1144
|
+
// Honest zero geometry deadlocks layout-driven React: a component measures 0,
|
|
1145
|
+
// setState()s to "fix" it, re-renders, measures 0 again — forever (MUI Popper /
|
|
1146
|
+
// autosizers / virtual lists / useLayoutEffect). So instead of a real layout
|
|
1147
|
+
// engine we return a cheap synthetic box model whose values are (1) non-zero &
|
|
1148
|
+
// plausible, (2) STABLE for the same DOM state, (3) internally consistent
|
|
1149
|
+
// (children fit parents; right-left === width). Width depends only on ancestors,
|
|
1150
|
+
// height only on descendants — no cycle. Memoized per node keyed on
|
|
1151
|
+
// Document.__version (`__rw`/`__rh`), like __computedStyle, so a mutation
|
|
1152
|
+
// invalidates it and a measure→setState→measure cycle sees the same value twice
|
|
1153
|
+
// and settles. Test-time only — parse/query/match/events never call these.
|
|
1154
|
+
const INLINE_TAGS = new Set(['a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn', 'em', 'i', 'img', 'input', 'kbd', 'label', 'mark', 'output', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'select', 'small', 'span', 'strong', 'sub', 'sup', 'textarea', 'time', 'u', 'var', 'wbr']);
|
|
1155
|
+
const LINE_HEIGHT = 18;
|
|
1156
|
+
const CHAR_W = 8;
|
|
1157
|
+
function viewportWidth(el) { const w = el.ownerDocument && el.ownerDocument.defaultView; return (w && w.innerWidth) || 1024; }
|
|
1158
|
+
function synthWidth(el) {
|
|
1159
|
+
const doc = el.ownerDocument || el;
|
|
1160
|
+
const v = doc.__version || 0;
|
|
1161
|
+
const c = el.__rw;
|
|
1162
|
+
if (c && c.v === v) return c.w;
|
|
1163
|
+
const p = el.parentNode;
|
|
1164
|
+
const pw = (p && p.nodeType === ELEMENT_NODE) ? synthWidth(p) : viewportWidth(el);
|
|
1165
|
+
// inline boxes shrink-wrap their text (capped at the parent); blocks fill it
|
|
1166
|
+
const w = INLINE_TAGS.has(el.localName)
|
|
1167
|
+
? Math.min(pw, Math.max(CHAR_W, (el.textContent || '').length * CHAR_W))
|
|
1168
|
+
: pw;
|
|
1169
|
+
el.__rw = { v, w };
|
|
1170
|
+
return w;
|
|
1171
|
+
}
|
|
1172
|
+
function synthHeight(el) {
|
|
1173
|
+
const doc = el.ownerDocument || el;
|
|
1174
|
+
const v = doc.__version || 0;
|
|
1175
|
+
const c = el.__rh;
|
|
1176
|
+
if (c && c.v === v) return c.h;
|
|
1177
|
+
const kids = el.__children ? el.__children() : [];
|
|
1178
|
+
let elemKids = 0, stacked = 0;
|
|
1179
|
+
for (let i = 0; i < kids.length; i++) { const k = kids[i]; if (k.nodeType === ELEMENT_NODE) { elemKids++; stacked += synthHeight(k); } }
|
|
1180
|
+
let h;
|
|
1181
|
+
if (elemKids) h = Math.max(LINE_HEIGHT, stacked); // block flow: element children stack
|
|
1182
|
+
else {
|
|
1183
|
+
const chars = (el.textContent || '').length;
|
|
1184
|
+
const perLine = Math.max(1, Math.floor(synthWidth(el) / CHAR_W));
|
|
1185
|
+
h = Math.max(1, Math.ceil(chars / perLine)) * LINE_HEIGHT;
|
|
1186
|
+
}
|
|
1187
|
+
el.__rh = { v, h };
|
|
1188
|
+
return h;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1143
1191
|
// child index of `node` within its parent (per spec: the node's "index")
|
|
1144
1192
|
function nodeIndex(node) {
|
|
1145
1193
|
const p = node.parentNode;
|
package/src/runtime/index.mjs
CHANGED
|
@@ -18,6 +18,10 @@ export * from './dom.mjs';
|
|
|
18
18
|
// or force 'wasm' | 'native' | 'auto'. See parser.mjs.
|
|
19
19
|
export { setParser, setParserMode, getParser } from './parser.mjs';
|
|
20
20
|
|
|
21
|
+
// Clock seam (public API): install a virtual clock so time-gated rAF loops (MUI
|
|
22
|
+
// transitions) terminate under a synchronous timer-drain render tier. See window.mjs.
|
|
23
|
+
export { setClock } from './window.mjs';
|
|
24
|
+
|
|
21
25
|
// Parse cache: the SoA buffer is READ-ONLY (every mutation goes to a Document's
|
|
22
26
|
// own __kids/__attrs/__cache overlay, never the buffer), so the same buffer can
|
|
23
27
|
// back many Documents. Test suites call setup with the SAME html per file
|
package/src/runtime/stubs.mjs
CHANGED
|
@@ -66,10 +66,34 @@ export class Storage {
|
|
|
66
66
|
clear() { this.__map.clear(); }
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
// matchMedia: parse width/height/orientation features and evaluate them against
|
|
70
|
+
// the window's innerWidth/innerHeight (default 1024×768). Responsive components
|
|
71
|
+
// (`useMediaQuery`, MUI breakpoints) branch on this; a flat `false` made them
|
|
72
|
+
// mis-branch and thrash. `matches` is a getter so it re-evaluates if innerWidth
|
|
73
|
+
// is overridden — still stable for a given viewport. Unknown/feature-less queries
|
|
74
|
+
// stay honestly `false`.
|
|
75
|
+
const MQ_FEATURE = /\(\s*(min-width|max-width|min-height|max-height|orientation)\s*:\s*([^)]+?)\s*\)/g;
|
|
76
|
+
function evalMediaQuery(query, win) {
|
|
77
|
+
const W = (win && win.innerWidth) || 1024;
|
|
78
|
+
const H = (win && win.innerHeight) || 768;
|
|
79
|
+
const q = String(query).toLowerCase();
|
|
80
|
+
let matched = true, any = false, m;
|
|
81
|
+
MQ_FEATURE.lastIndex = 0;
|
|
82
|
+
while ((m = MQ_FEATURE.exec(q))) {
|
|
83
|
+
any = true;
|
|
84
|
+
const feat = m[1], val = m[2].trim();
|
|
85
|
+
if (feat === 'min-width') matched = matched && W >= parseFloat(val);
|
|
86
|
+
else if (feat === 'max-width') matched = matched && W <= parseFloat(val);
|
|
87
|
+
else if (feat === 'min-height') matched = matched && H >= parseFloat(val);
|
|
88
|
+
else if (feat === 'max-height') matched = matched && H <= parseFloat(val);
|
|
89
|
+
else matched = matched && val === (W >= H ? 'landscape' : 'portrait'); // orientation
|
|
90
|
+
}
|
|
91
|
+
return any && matched;
|
|
92
|
+
}
|
|
93
|
+
export function makeMatchMedia(win) {
|
|
70
94
|
return (query) => ({
|
|
71
|
-
|
|
72
|
-
|
|
95
|
+
media: String(query),
|
|
96
|
+
get matches() { return evalMediaQuery(query, win); },
|
|
73
97
|
onchange: null,
|
|
74
98
|
addEventListener() {},
|
|
75
99
|
removeEventListener() {},
|
|
@@ -82,17 +106,46 @@ export function makeMatchMedia() {
|
|
|
82
106
|
// getComputedStyle lives in cascade.mjs — it resolves injected <style> sheets +
|
|
83
107
|
// inline styles (a strict superset of the old inline-only stub once here).
|
|
84
108
|
|
|
85
|
-
|
|
109
|
+
// Resize/Intersection observers fire their callback ONCE, async, with a single
|
|
110
|
+
// initial entry — never on a loop. Components that gate first render on the first
|
|
111
|
+
// observer callback (lazy lists, MUI autosize, visibility-gated mounts) then
|
|
112
|
+
// proceed and settle. The entry's rect is the element's synthetic box (dom.mjs).
|
|
113
|
+
function rectOf(el) {
|
|
114
|
+
const r = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : { width: 0, height: 0 };
|
|
115
|
+
return { x: 0, y: 0, top: 0, left: 0, right: r.width, bottom: r.height, width: r.width, height: r.height };
|
|
116
|
+
}
|
|
117
|
+
export class ResizeObserver {
|
|
86
118
|
constructor(cb) { this.__cb = cb; }
|
|
87
|
-
observe() {
|
|
119
|
+
observe(el) {
|
|
120
|
+
const cb = this.__cb;
|
|
121
|
+
Promise.resolve().then(() => {
|
|
122
|
+
const r = rectOf(el);
|
|
123
|
+
const entry = { target: el, contentRect: r, borderBoxSize: [{ inlineSize: r.width, blockSize: r.height }], contentBoxSize: [{ inlineSize: r.width, blockSize: r.height }], devicePixelContentBoxSize: [{ inlineSize: r.width, blockSize: r.height }] };
|
|
124
|
+
try { cb([entry], this); } catch { /* don't crash the render tier on a callback throw */ }
|
|
125
|
+
});
|
|
126
|
+
}
|
|
88
127
|
unobserve() {}
|
|
89
128
|
disconnect() {}
|
|
90
|
-
takeRecords() { return []; }
|
|
91
129
|
}
|
|
92
|
-
export class IntersectionObserver
|
|
93
|
-
constructor(cb
|
|
130
|
+
export class IntersectionObserver {
|
|
131
|
+
constructor(cb, opts = {}) {
|
|
132
|
+
this.__cb = cb;
|
|
133
|
+
this.root = opts.root ?? null;
|
|
134
|
+
this.rootMargin = opts.rootMargin ?? '0px';
|
|
135
|
+
this.thresholds = Array.isArray(opts.threshold) ? opts.threshold : [opts.threshold ?? 0];
|
|
136
|
+
}
|
|
137
|
+
observe(el) {
|
|
138
|
+
const cb = this.__cb;
|
|
139
|
+
Promise.resolve().then(() => {
|
|
140
|
+
const r = rectOf(el);
|
|
141
|
+
const entry = { target: el, isIntersecting: true, intersectionRatio: 1, boundingClientRect: r, intersectionRect: r, rootBounds: null, time: 0 };
|
|
142
|
+
try { cb([entry], this); } catch { /* same */ }
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
unobserve() {}
|
|
146
|
+
disconnect() {}
|
|
147
|
+
takeRecords() { return []; }
|
|
94
148
|
}
|
|
95
|
-
export class ResizeObserver extends ObserverStub {}
|
|
96
149
|
|
|
97
150
|
// MutationObserver: real enough to be useful — fires on observed mutations would
|
|
98
151
|
// require hooking every mutation; v1 is an honest queue-based stub that records
|
package/src/runtime/window.mjs
CHANGED
|
@@ -81,6 +81,38 @@ function tagClass(matcher) {
|
|
|
81
81
|
const hostSetTimeout = globalThis.setTimeout;
|
|
82
82
|
const hostClearTimeout = globalThis.clearTimeout;
|
|
83
83
|
|
|
84
|
+
// MessageChannel/MessagePort — React 19's scheduler posts its work loop through a
|
|
85
|
+
// MessagePort hop (falling back to setTimeout only if absent). We ship a real,
|
|
86
|
+
// self-contained polyfill whose delivery is scheduled via the LIVE globalThis.
|
|
87
|
+
// setTimeout (read at call time, like rAF), so a render tier that owns setTimeout
|
|
88
|
+
// (a virtual-clock pump) catches every scheduler hop → React runs in virtual time,
|
|
89
|
+
// bounded by the drain/deadline — and it exists in the bare V8 isolate, which has
|
|
90
|
+
// no host MessageChannel. See MESSAGECHANNEL-VIRTUALIZE.md + the clock note in
|
|
91
|
+
// CLAUDE.md. Delivery uses delay 0 (a yield, not elapsed time — animations get
|
|
92
|
+
// their time from rAF's 16ms frame).
|
|
93
|
+
class MessagePort extends EventTarget {
|
|
94
|
+
constructor() { super(); this.onmessage = null; this.onmessageerror = null; this.__other = null; }
|
|
95
|
+
start() {}
|
|
96
|
+
close() { this.__other = null; }
|
|
97
|
+
postMessage(data) {
|
|
98
|
+
const target = this.__other;
|
|
99
|
+
if (!target) return;
|
|
100
|
+
(globalThis.setTimeout || hostSetTimeout)(() => {
|
|
101
|
+
const ev = new Event('message'); ev.data = data;
|
|
102
|
+
if (typeof target.onmessage === 'function') target.onmessage(ev);
|
|
103
|
+
target.dispatchEvent(ev);
|
|
104
|
+
}, 0);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
class MessageChannel {
|
|
108
|
+
constructor() {
|
|
109
|
+
this.port1 = new MessagePort();
|
|
110
|
+
this.port2 = new MessagePort();
|
|
111
|
+
this.port1.__other = this.port2;
|
|
112
|
+
this.port2.__other = this.port1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
84
116
|
// Env-independent lazy global factories — built ONCE at module load, shared by
|
|
85
117
|
// every window (none capture document/url/windowProxy). Materialization still
|
|
86
118
|
// self-replaces onto the per-env base, so each env gets its own instance; only
|
|
@@ -89,12 +121,16 @@ const SHARED_LAZY = {
|
|
|
89
121
|
customElements: () => makeCustomElements(), // fresh registry per env, built on first access
|
|
90
122
|
localStorage: () => new Storage(),
|
|
91
123
|
sessionStorage: () => new Storage(),
|
|
92
|
-
matchMedia: () => makeMatchMedia(),
|
|
93
124
|
getComputedStyle: () => makeGetComputedStyle(),
|
|
94
125
|
IntersectionObserver: () => IntersectionObserver,
|
|
95
126
|
ResizeObserver: () => ResizeObserver,
|
|
96
|
-
|
|
97
|
-
|
|
127
|
+
// rAF: schedule via the LIVE setTimeout (so a render tier that swaps in a timer
|
|
128
|
+
// queue catches the reschedule) with a non-zero frame delay, and stamp the
|
|
129
|
+
// callback with the injectable clock now() — a virtual clock that ADVANCES per
|
|
130
|
+
// frame lets time-gated transitions (MUI Fade/Grow, react-transition-group:
|
|
131
|
+
// progress = (now-start)/duration) reach progress>=1 and stop rescheduling.
|
|
132
|
+
requestAnimationFrame: () => (cb) => (globalThis.setTimeout || hostSetTimeout)(() => cb(now()), 16),
|
|
133
|
+
cancelAnimationFrame: () => (id) => (globalThis.clearTimeout || hostClearTimeout)(id),
|
|
98
134
|
navigator: () => ({
|
|
99
135
|
userAgent: 'Mozilla/5.0 (turbo-dom) AppleWebKit/537.36',
|
|
100
136
|
platform: 'turbo-dom', vendor: '', language: 'en-US', languages: ['en-US'],
|
|
@@ -104,7 +140,7 @@ const SHARED_LAZY = {
|
|
|
104
140
|
permissions: { query: async () => ({ state: 'prompt', addEventListener() {}, removeEventListener() {} }) },
|
|
105
141
|
sendBeacon: () => true, vibrate: () => false,
|
|
106
142
|
}),
|
|
107
|
-
performance: () => ({ now
|
|
143
|
+
performance: () => ({ now, timeOrigin: 0, mark() {}, measure() {}, getEntriesByName: () => [], getEntriesByType: () => [], clearMarks() {}, clearMeasures() {} }),
|
|
108
144
|
Storage: () => Storage,
|
|
109
145
|
devicePixelRatio: () => 1,
|
|
110
146
|
innerWidth: () => 1024,
|
|
@@ -154,6 +190,8 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
|
|
|
154
190
|
// subsystem grouping: history co-materializes with (and shares) location
|
|
155
191
|
location: () => makeLocation(url),
|
|
156
192
|
history: () => makeHistory(windowProxy.location),
|
|
193
|
+
// per-env so matchMedia can evaluate queries against THIS window's viewport
|
|
194
|
+
matchMedia: () => makeMatchMedia(windowProxy),
|
|
157
195
|
};
|
|
158
196
|
|
|
159
197
|
windowProxy = new Proxy(base, {
|
|
@@ -210,6 +248,17 @@ const performanceNow = typeof hostPerformance?.now === 'function'
|
|
|
210
248
|
? () => hostPerformance.now()
|
|
211
249
|
: () => Date.now();
|
|
212
250
|
|
|
251
|
+
// Injectable clock. `window.performance.now()` and the rAF callback timestamp both
|
|
252
|
+
// read through now(), so a single setClock() drives both. Default (null) → real
|
|
253
|
+
// host clock, no behavior change for vitest/jest. The render tier installs a VIRTUAL
|
|
254
|
+
// clock that advances per drained frame so time-gated rAF loops terminate (see the
|
|
255
|
+
// synthetic-geometry/clock note in CLAUDE.md). Resolves performanceNow lazily, so it
|
|
256
|
+
// stays defined regardless of where setClock is called. (Date.now is the embedder's
|
|
257
|
+
// to override — turbo-dom never shadows it.)
|
|
258
|
+
let __clock = null;
|
|
259
|
+
function now() { return __clock ? __clock() : performanceNow(); }
|
|
260
|
+
export function setClock(fn) { __clock = typeof fn === 'function' ? fn : null; }
|
|
261
|
+
|
|
213
262
|
// base64 without depending on the Node `Buffer` global — prefer the platform
|
|
214
263
|
// btoa/atob, then Buffer (Node), then a pure-JS fallback so a bare-isolate page
|
|
215
264
|
// that calls btoa/atob doesn't throw.
|
|
@@ -385,7 +434,7 @@ const STATIC_BASE = {
|
|
|
385
434
|
crypto: globalThis.crypto, Crypto: globalThis.Crypto, SubtleCrypto: globalThis.SubtleCrypto,
|
|
386
435
|
btoa: (s) => turboBtoa(String(s)),
|
|
387
436
|
atob: (s) => turboAtob(String(s)),
|
|
388
|
-
MessageChannel
|
|
437
|
+
MessageChannel, MessagePort,
|
|
389
438
|
BroadcastChannel: globalThis.BroadcastChannel, EventSource: globalThis.EventSource,
|
|
390
439
|
reportError: (e) => { void e; },
|
|
391
440
|
requestIdleCallback: (cb) => hostSetTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0),
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|