@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 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.3"
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.3"
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.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=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;
@@ -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
@@ -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