@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 CHANGED
@@ -490,7 +490,7 @@ dependencies = [
490
490
 
491
491
  [[package]]
492
492
  name = "turbo-dom-parser"
493
- version = "0.0.1"
493
+ version = "0.2.4"
494
494
  dependencies = [
495
495
  "html5ever",
496
496
  "markup5ever_rcdom",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "turbo-dom-parser"
3
- version = "0.0.1"
3
+ version = "0.2.4"
4
4
  edition = "2021"
5
5
  description = "html5ever-backed native parser for turbo-dom (Layer 1)"
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.2.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=92 --test-coverage-branches=88 'test/*.mjs'",
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
@@ -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 zeroRect(); }
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
- // honest zero geometry (no layout)
834
- get offsetWidth() { return 0; } get offsetHeight() { return 0; }
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 0; } get clientHeight() { return 0; }
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 0; } get scrollHeight() { return 0; }
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;
@@ -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
@@ -66,10 +66,34 @@ export class Storage {
66
66
  clear() { this.__map.clear(); }
67
67
  }
68
68
 
69
- export function makeMatchMedia() {
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
- matches: false, // honest: no real media context in a headless runner
72
- media: query,
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
- class ObserverStub {
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 extends ObserverStub {
93
- constructor(cb) { super(cb); this.root = null; this.rootMargin = '0px'; this.thresholds = [0]; }
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
@@ -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
- requestAnimationFrame: () => (cb) => hostSetTimeout(() => cb(performanceNow()), 0),
97
- cancelAnimationFrame: () => (id) => hostClearTimeout(id),
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: performanceNow, timeOrigin: 0, mark() {}, measure() {}, getEntriesByName: () => [], getEntriesByType: () => [], clearMarks() {}, clearMeasures() {} }),
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: globalThis.MessageChannel, MessagePort: globalThis.MessagePort,
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