@miaskiewicz/turbo-dom 0.2.2 → 0.2.3
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/stubs.mjs +62 -9
- package/src/runtime/window.mjs +2 -1
- 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.3",
|
|
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/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
|
@@ -89,7 +89,6 @@ const SHARED_LAZY = {
|
|
|
89
89
|
customElements: () => makeCustomElements(), // fresh registry per env, built on first access
|
|
90
90
|
localStorage: () => new Storage(),
|
|
91
91
|
sessionStorage: () => new Storage(),
|
|
92
|
-
matchMedia: () => makeMatchMedia(),
|
|
93
92
|
getComputedStyle: () => makeGetComputedStyle(),
|
|
94
93
|
IntersectionObserver: () => IntersectionObserver,
|
|
95
94
|
ResizeObserver: () => ResizeObserver,
|
|
@@ -154,6 +153,8 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
|
|
|
154
153
|
// subsystem grouping: history co-materializes with (and shares) location
|
|
155
154
|
location: () => makeLocation(url),
|
|
156
155
|
history: () => makeHistory(windowProxy.location),
|
|
156
|
+
// per-env so matchMedia can evaluate queries against THIS window's viewport
|
|
157
|
+
matchMedia: () => makeMatchMedia(windowProxy),
|
|
157
158
|
};
|
|
158
159
|
|
|
159
160
|
windowProxy = new Proxy(base, {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|