@miaskiewicz/turbo-dom 0.1.28 → 0.1.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
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",
@@ -0,0 +1,182 @@
1
+ // Partial computed-style cascade — resolves getComputedStyle from injected <style>
2
+ // sheets (emotion/MUI `.css-HASH { … }`), inline styles, and specificity ordering.
3
+ //
4
+ // PERF: every hot path (parse, querySelectorAll, getElementsBy*, matching, events)
5
+ // NEVER calls getComputedStyle — only test assertions do. So all work here is lazy,
6
+ // gated behind getComputedStyle, and memoized on Document.__version. Zero bytes are
7
+ // added to any benchmarked path.
8
+ //
9
+ // HONEST: this only ever returns values that come from a REAL matched rule or inline
10
+ // style. A property no stylesheet/inline set still reads '' — it never invents
11
+ // layout/cascade numbers. Out of scope (return ''): @media/@supports/@keyframes,
12
+ // :hover & other stateful pseudo-classes, pseudo-elements, full inheritance,
13
+ // CSS custom-property resolution, length normalization.
14
+
15
+ import { matchesSelector } from './selectors.mjs';
16
+
17
+ const kebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
18
+
19
+ // Longhand → shorthand fallback for single-token shorthands (mirrors dom.mjs styleGet).
20
+ const SHORTHAND_OF = {
21
+ 'background-color': 'background',
22
+ 'margin-top': 'margin', 'margin-right': 'margin', 'margin-bottom': 'margin', 'margin-left': 'margin',
23
+ 'padding-top': 'padding', 'padding-right': 'padding', 'padding-bottom': 'padding', 'padding-left': 'padding',
24
+ };
25
+
26
+ // Length properties whose bare `0` real browsers serialize as `0px` in computed style.
27
+ const LENGTH_PROPS = new Set([
28
+ 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
29
+ 'top', 'right', 'bottom', 'left', 'flex-basis', 'gap', 'row-gap', 'column-gap',
30
+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
31
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
32
+ 'border-width', 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
33
+ 'font-size', 'letter-spacing', 'word-spacing', 'text-indent',
34
+ ]);
35
+
36
+ const BORDER_STYLES = new Set(['none', 'hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset']);
37
+
38
+ // Expand the common shorthands into longhands so longhand computed getters resolve
39
+ // (e.g. `border:1px solid` → borderWidth '1px'). Cheap, test-time only; later
40
+ // declarations of the same longhand override naturally (cascade order preserved).
41
+ function setProp(map, name, val) {
42
+ map.set(name, val);
43
+ if (name === 'margin' || name === 'padding') {
44
+ const t = val.trim().split(/\s+/);
45
+ const top = t[0], right = t[1] ?? top, bottom = t[2] ?? top, left = t[3] ?? right;
46
+ map.set(name + '-top', top); map.set(name + '-right', right);
47
+ map.set(name + '-bottom', bottom); map.set(name + '-left', left);
48
+ } else if (name === 'border') {
49
+ let width, style, color;
50
+ for (const p of val.trim().split(/\s+/)) {
51
+ if (BORDER_STYLES.has(p)) style = p;
52
+ else if (/^(thin|medium|thick)$/.test(p) || /^[\d.]+(px|em|rem|%|pt|vh|vw)?$/.test(p)) width = p;
53
+ else color = p;
54
+ }
55
+ if (width !== undefined) { map.set('border-width', width); for (const s of ['top', 'right', 'bottom', 'left']) map.set(`border-${s}-width`, width); }
56
+ if (style !== undefined) map.set('border-style', style);
57
+ if (color !== undefined) map.set('border-color', color);
58
+ } else if (name === 'background' && !/\s/.test(val.trim())) {
59
+ map.set('background-color', val.trim());
60
+ }
61
+ }
62
+
63
+ function parseDecls(text, into) {
64
+ const map = into || new Map();
65
+ for (const decl of text.split(';')) {
66
+ const c = decl.indexOf(':');
67
+ if (c === -1) continue;
68
+ const name = decl.slice(0, c).trim().toLowerCase();
69
+ if (!name) continue;
70
+ setProp(map, name, decl.slice(c + 1).trim().replace(/\s*!\s*important\s*$/i, ''));
71
+ }
72
+ return map;
73
+ }
74
+
75
+ // rough WHATWG specificity: a*10000 + b*100 + c (ids, classes/attrs/pseudos, types)
76
+ function specificity(sel) {
77
+ const a = (sel.match(/#[\w-]+/g) || []).length;
78
+ const b = (sel.match(/\.[\w-]+|\[[^\]]*\]|:[\w-]+/g) || []).length;
79
+ const c = (sel.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
80
+ return a * 10000 + b * 100 + c;
81
+ }
82
+
83
+ // Brace-depth scan: emit only depth-1 rules whose selector isn't an at-rule; the
84
+ // bodies of @media/@keyframes (nested braces) are skipped wholesale.
85
+ function parseStylesheet(css, startOrder, rules) {
86
+ css = css.replace(/\/\*[\s\S]*?\*\//g, '');
87
+ let order = startOrder, i = 0;
88
+ const n = css.length;
89
+ while (i < n) {
90
+ let j = i;
91
+ while (j < n && css[j] !== '{' && css[j] !== '}') j++;
92
+ if (j >= n) break;
93
+ if (css[j] === '}') { i = j + 1; continue; }
94
+ const sel = css.slice(i, j).trim();
95
+ let depth = 1, k = j + 1;
96
+ while (k < n && depth > 0) { const ch = css[k]; if (ch === '{') depth++; else if (ch === '}') depth--; k++; }
97
+ if (sel && sel[0] !== '@') {
98
+ const decls = parseDecls(css.slice(j + 1, k - 1));
99
+ if (decls.size) for (const part of sel.split(',')) {
100
+ const t = part.trim();
101
+ if (t) rules.push({ selector: t, decls, spec: specificity(t), order: order++ });
102
+ }
103
+ } else { order++; }
104
+ i = k;
105
+ }
106
+ return order;
107
+ }
108
+
109
+ function buildIndex(document) {
110
+ const styles = document.getElementsByTagName('style');
111
+ const rules = [];
112
+ let order = 0;
113
+ for (let i = 0; i < styles.length; i++) order = parseStylesheet(styles[i].textContent || '', order, rules);
114
+ return rules;
115
+ }
116
+
117
+ function getIndex(doc, v) {
118
+ const cached = doc.__styleIndex;
119
+ if (cached && cached.v === v) return cached.rules;
120
+ const rules = buildIndex(doc);
121
+ doc.__styleIndex = { v, rules };
122
+ return rules;
123
+ }
124
+
125
+ function resolve(el, rules) {
126
+ const out = new Map();
127
+ const matched = [];
128
+ for (let i = 0; i < rules.length; i++) {
129
+ const r = rules[i];
130
+ try { if (matchesSelector(el, r.selector)) matched.push(r); } catch { /* unsupported selector → skip */ }
131
+ }
132
+ matched.sort((x, y) => x.spec - y.spec || x.order - y.order);
133
+ for (const r of matched) for (const [k, val] of r.decls) out.set(k, val);
134
+ // inline style wins over stylesheet rules
135
+ const inline = el.getAttribute && el.getAttribute('style');
136
+ if (inline) parseDecls(inline, out);
137
+ return out;
138
+ }
139
+
140
+ function lookup(map, prop) {
141
+ let v = map.get(prop);
142
+ if (v === undefined) {
143
+ const sh = SHORTHAND_OF[prop];
144
+ if (sh) { const s = map.get(sh); if (s !== undefined && !/\s/.test(s.trim())) v = s; }
145
+ }
146
+ if (v === undefined) return '';
147
+ // px-normalize a bare `0` for length properties (browsers report `0px`)
148
+ if (v === '0' && LENGTH_PROPS.has(prop)) return '0px';
149
+ return v;
150
+ }
151
+
152
+ function makeProxy(map, v) {
153
+ return new Proxy({}, {
154
+ get(_t, key) {
155
+ if (key === '__v') return v;
156
+ if (key === '__honest') return 'computed style resolves injected <style> + inline; no layout/@media/state';
157
+ if (key === 'getPropertyValue') return (p) => lookup(map, String(p).toLowerCase());
158
+ if (key === 'getPropertyPriority') return () => '';
159
+ if (key === 'length') return map.size;
160
+ if (key === 'item') return (i) => [...map.keys()][i] ?? '';
161
+ if (key === Symbol.iterator) { const ks = [...map.keys()]; return ks[Symbol.iterator].bind(ks); }
162
+ if (typeof key !== 'string') return undefined;
163
+ return lookup(map, kebab(key));
164
+ },
165
+ });
166
+ }
167
+
168
+ // getComputedStyle(el): resolves cascade, memoized per element on the current
169
+ // Document.__version (any style/DOM mutation bumps it → cache auto-invalidates).
170
+ export function makeGetComputedStyle() {
171
+ return (el) => {
172
+ if (!el) return makeProxy(new Map(), -1);
173
+ const doc = el.ownerDocument;
174
+ const v = doc ? (doc.__version || 0) : 0;
175
+ const cached = el.__computedStyle;
176
+ if (cached && cached.__v === v) return cached;
177
+ const map = doc ? resolve(el, getIndex(doc, v)) : new Map();
178
+ const proxy = makeProxy(map, v);
179
+ el.__computedStyle = proxy;
180
+ return proxy;
181
+ };
182
+ }
@@ -191,15 +191,19 @@ export class Node extends EventTarget {
191
191
  }
192
192
  normalize() {
193
193
  const kids = this.__children();
194
+ let changed = false;
194
195
  for (let i = kids.length - 1; i > 0; i--) {
195
196
  if (kids[i].nodeType === TEXT_NODE && kids[i - 1].nodeType === TEXT_NODE) {
196
- kids[i - 1]._data += kids[i].data; kids[i].parentNode = null; kids.splice(i, 1);
197
+ kids[i - 1]._data += kids[i].data; kids[i].parentNode = null; kids.splice(i, 1); changed = true;
197
198
  }
198
199
  }
199
200
  for (let i = kids.length - 1; i >= 0; i--) {
200
- if (kids[i].nodeType === TEXT_NODE && kids[i].data === '') { kids[i].parentNode = null; kids.splice(i, 1); }
201
+ if (kids[i].nodeType === TEXT_NODE && kids[i].data === '') { kids[i].parentNode = null; kids.splice(i, 1); changed = true; }
201
202
  else if (kids[i].nodeType === ELEMENT_NODE) kids[i].normalize();
202
203
  }
204
+ // normalize mutates __kids in place — bump __version (invalidate cached queries)
205
+ // and feed MutationObservers, like every other childList mutation path.
206
+ if (changed) notifyMutation(this, { type: 'childList', target: this, addedNodes: [], removedNodes: [], nextSibling: null });
203
207
  }
204
208
  replaceChildren(...nodes) { this.__kids = []; this.__touch(); for (const n of nodes) this.appendChild(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n); }
205
209
  // bitmask: 1 DISCONNECTED, 2 PRECEDING, 4 FOLLOWING, 8 CONTAINS, 16 CONTAINED_BY
@@ -355,12 +359,14 @@ export class Element extends Node {
355
359
  // elements are inflated for traversal but never have attrs read).
356
360
  __buildAttrs() { const doc = this.ownerDocument, buf = doc && doc.__buf; return (this.__attrIdx >= 0 && buf) ? buf.attrs(this.__attrIdx) : []; }
357
361
  getAttribute(name) {
362
+ if (!this.__ns) name = ('' + name).toLowerCase(); // HTML attr names are ASCII-lowercased (SVG/MathML keep case)
358
363
  const at = this.__attrs;
359
364
  if (at !== undefined) { for (let i = 0; i < at.length; i++) if (at[i].name === name) return at[i].value; return null; }
360
365
  const doc = this.ownerDocument, buf = doc && doc.__buf; // lazy: read column, don't materialize
361
366
  return (this.__attrIdx >= 0 && buf) ? buf.attrGet(this.__attrIdx, name) : null;
362
367
  }
363
368
  hasAttribute(name) {
369
+ if (!this.__ns) name = ('' + name).toLowerCase();
364
370
  const at = this.__attrs;
365
371
  if (at !== undefined) { for (let i = 0; i < at.length; i++) if (at[i].name === name) return true; return false; }
366
372
  const doc = this.ownerDocument, buf = doc && doc.__buf;
@@ -368,6 +374,7 @@ export class Element extends Node {
368
374
  }
369
375
  getAttributeNames() { return (this.__attrs ?? (this.__attrs = this.__buildAttrs())).map((a) => a.name); }
370
376
  setAttribute(name, value) {
377
+ if (!this.__ns) name = ('' + name).toLowerCase();
371
378
  if (this.__attrs === undefined) this.__attrs = this.__buildAttrs();
372
379
  const a = this.__attrs.find((x) => x.name === name);
373
380
  const old = a ? a.value : null;
@@ -376,6 +383,7 @@ export class Element extends Node {
376
383
  notifyMutation(this, { type: 'attributes', target: this, attributeName: name, oldValue: old, addedNodes: [], removedNodes: [] });
377
384
  }
378
385
  removeAttribute(name) {
386
+ if (!this.__ns) name = ('' + name).toLowerCase();
379
387
  if (this.__attrs === undefined) this.__attrs = this.__buildAttrs();
380
388
  const a = this.__attrs.find((x) => x.name === name);
381
389
  this.__attrs = this.__attrs.filter((x) => x.name !== name);
@@ -453,13 +461,13 @@ export class Element extends Node {
453
461
  const t = this.localName;
454
462
  if (t === 'select') { for (const o of this.getElementsByTagName('option')) o.selected = (o.value === String(x)); return; }
455
463
  if (t === 'option') { this.setAttribute('value', x); return; }
456
- // typed <input> sanitizes per the value-sanitization algorithm: invalid
457
- // values for date/time/number/etc are rejected (value left unchanged), so
458
- // user-event typing them char-by-char doesn't emit bogus partial values.
464
+ // typed <input> runs the WHATWG value-sanitization algorithm: a value that
465
+ // isn't valid for date/time/number/etc becomes the EMPTY string (not the prior
466
+ // value). Matches real browsers — React's value tracker then sees the change and
467
+ // fires onChange, where retaining the old value would silently swallow it.
459
468
  if (t === 'input') {
460
469
  const sanitized = sanitizeInputValue((this.getAttribute('type') || 'text').toLowerCase(), String(x));
461
- if (sanitized === null) return; // invalid → reject, keep current value
462
- this.__value = sanitized;
470
+ this.__value = sanitized === null ? '' : sanitized;
463
471
  } else {
464
472
  this.__value = String(x);
465
473
  }
@@ -505,6 +513,33 @@ export class Element extends Node {
505
513
  set name(x) { this.setAttribute('name', x); }
506
514
  get placeholder() { return this.getAttribute('placeholder') ?? ''; }
507
515
  set placeholder(x) { this.setAttribute('placeholder', x); }
516
+ // IDL-attribute reflection: React assigns these as element PROPERTIES; without a
517
+ // reflecting setter the value never reaches a content attribute, so getAttribute/
518
+ // toHaveAttribute see nothing. Mirrors the canonical lowercased attribute name.
519
+ get inputMode() { return this.getAttribute('inputmode') ?? ''; }
520
+ set inputMode(x) { this.setAttribute('inputmode', x); }
521
+ get spellcheck() { const v = this.getAttribute('spellcheck'); return v == null ? true : v !== 'false'; }
522
+ set spellcheck(x) { this.setAttribute('spellcheck', x ? 'true' : 'false'); }
523
+ get autocomplete() { return this.getAttribute('autocomplete') ?? ''; }
524
+ set autocomplete(x) { this.setAttribute('autocomplete', x); }
525
+ get accept() { return this.getAttribute('accept') ?? ''; }
526
+ set accept(x) { this.setAttribute('accept', x); }
527
+ get min() { return this.getAttribute('min') ?? ''; }
528
+ set min(x) { this.setAttribute('min', x); }
529
+ get max() { return this.getAttribute('max') ?? ''; }
530
+ set max(x) { this.setAttribute('max', x); }
531
+ get step() { return this.getAttribute('step') ?? ''; }
532
+ set step(x) { this.setAttribute('step', x); }
533
+ get pattern() { return this.getAttribute('pattern') ?? ''; }
534
+ set pattern(x) { this.setAttribute('pattern', x); }
535
+ get maxLength() { return this.hasAttribute('maxlength') ? parseInt(this.getAttribute('maxlength'), 10) : -1; }
536
+ set maxLength(x) { this.setAttribute('maxlength', String(x)); }
537
+ get minLength() { return this.hasAttribute('minlength') ? parseInt(this.getAttribute('minlength'), 10) : -1; }
538
+ set minLength(x) { this.setAttribute('minlength', String(x)); }
539
+ get colSpan() { const v = parseInt(this.getAttribute('colspan'), 10); return v > 0 ? v : 1; }
540
+ set colSpan(x) { this.setAttribute('colspan', String(x)); }
541
+ get rowSpan() { const v = parseInt(this.getAttribute('rowspan'), 10); return v > 0 ? v : 1; }
542
+ set rowSpan(x) { this.setAttribute('rowspan', String(x)); }
508
543
  get href() { return this.getAttribute('href') ?? ''; }
509
544
  set href(x) { this.setAttribute('href', x); }
510
545
  get download() { return this.getAttribute('download') ?? ''; }
@@ -638,7 +673,7 @@ export class Element extends Node {
638
673
  // React's tracked value too, hiding the change and suppressing onChange.
639
674
  if (t === 'checkbox') {
640
675
  const old = this.checked; this.__checked = !old;
641
- return { undo: () => { this.__checked = old; } };
676
+ return { undo: () => { this.__checked = old; }, fireChange: true };
642
677
  }
643
678
  if (t === 'radio') {
644
679
  if (this.checked) return null;
@@ -646,7 +681,7 @@ export class Element extends Node {
646
681
  const prev = group.find((r) => r.checked) || null;
647
682
  group.forEach((r) => { r.__checked = false; });
648
683
  this.__checked = true;
649
- return { undo: () => { this.__checked = false; if (prev) prev.__checked = true; } };
684
+ return { undo: () => { this.__checked = false; if (prev) prev.__checked = true; }, fireChange: true };
650
685
  }
651
686
  return null;
652
687
  }
@@ -773,7 +808,20 @@ export class Element extends Node {
773
808
  requestSubmit() { this.submit(); }
774
809
  reset() {
775
810
  if (this.localName !== 'form') return;
776
- this.dispatchEvent(new Event('reset', { bubbles: true, cancelable: true }));
811
+ // abortable: if a listener preventDefault()s the reset event, controls keep state
812
+ if (!this.dispatchEvent(new Event('reset', { bubbles: true, cancelable: true }))) return;
813
+ for (const el of this.elements) {
814
+ const tag = el.localName;
815
+ if (tag === 'input') {
816
+ const t = (el.getAttribute('type') || 'text').toLowerCase();
817
+ if (t === 'checkbox' || t === 'radio') el.__checked = el.hasAttribute('checked');
818
+ else el.__value = undefined; // fall back to the value attribute (defaultValue)
819
+ } else if (tag === 'textarea') {
820
+ el.__value = el.textContent;
821
+ } else if (tag === 'select') {
822
+ for (const o of el.getElementsByTagName('option')) o.__selected = o.hasAttribute('selected');
823
+ }
824
+ }
777
825
  }
778
826
  }
779
827
 
@@ -959,7 +1007,7 @@ function makeStyle(el) {
959
1007
  };
960
1008
  return new Proxy({}, {
961
1009
  get(_t, key) {
962
- if (key === 'getPropertyValue') return (p) => parse().values.get(p) ?? '';
1010
+ if (key === 'getPropertyValue') return (p) => styleGet(parse().values, p);
963
1011
  if (key === 'getPropertyPriority') return (p) => parse().prio.get(p) ?? '';
964
1012
  if (key === 'setProperty') return (p, v, priority) => { const s = parse(); s.values.set(p, String(v)); if (priority) s.prio.set(p, 'important'); else s.prio.delete(p); write(s); };
965
1013
  if (key === 'removeProperty') return (p) => { const s = parse(); const v = s.values.get(p) ?? ''; s.values.delete(p); s.prio.delete(p); write(s); return v; };
@@ -969,7 +1017,7 @@ function makeStyle(el) {
969
1017
  if (key === 'cssFloat') return parse().values.get('float') ?? '';
970
1018
  if (key === Symbol.iterator) { const keys = [...parse().values.keys()]; return keys[Symbol.iterator].bind(keys); }
971
1019
  if (typeof key !== 'string') return undefined;
972
- return parse().values.get(kebab(key)) ?? '';
1020
+ return styleGet(parse().values, kebab(key));
973
1021
  },
974
1022
  set(_t, key, value) {
975
1023
  if (key === 'cssText') { el.setAttribute('style', String(value)); return true; }
@@ -980,6 +1028,24 @@ function makeStyle(el) {
980
1028
  }
981
1029
  const kebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
982
1030
 
1031
+ // We store inline styles verbatim (no shorthand expansion). For the common case
1032
+ // `style.background='red'; style.backgroundColor` we let a longhand READ fall back
1033
+ // to its shorthand when the shorthand holds a single token (one value that applies
1034
+ // to all sides / the only sub-property). Multi-token shorthands stay honest: the
1035
+ // longhand reads '' rather than guessing which token maps where.
1036
+ const SHORTHAND_OF = {
1037
+ 'background-color': 'background',
1038
+ 'margin-top': 'margin', 'margin-right': 'margin', 'margin-bottom': 'margin', 'margin-left': 'margin',
1039
+ 'padding-top': 'padding', 'padding-right': 'padding', 'padding-bottom': 'padding', 'padding-left': 'padding',
1040
+ };
1041
+ function styleGet(values, prop) {
1042
+ const direct = values.get(prop);
1043
+ if (direct !== undefined) return direct;
1044
+ const sh = SHORTHAND_OF[prop];
1045
+ if (sh) { const v = values.get(sh); if (v !== undefined && !/\s/.test(v.trim())) return v; }
1046
+ return '';
1047
+ }
1048
+
983
1049
  // WHATWG value-sanitization per input type. Returns the accepted value, or null
984
1050
  // if invalid (caller leaves the current value unchanged — like a real browser,
985
1051
  // which rejects partial/garbage input rather than storing it).
@@ -1016,6 +1082,24 @@ function makeDataset(el) {
1016
1082
  });
1017
1083
  }
1018
1084
 
1085
+ // adoptNode/importNode helper: a buffer-backed node carries __idx/__attrIdx into
1086
+ // the SoA buffer of its ORIGINAL document. Re-homing it to another document would
1087
+ // leave those indices reading through the NEW document's buffer (wrong/garbage, or
1088
+ // corruption). Materialize attrs + children off the current buffer FIRST, then sever
1089
+ // the buffer linkage, then switch ownerDocument — depth-first so each level reads its
1090
+ // own (old) buffer before being re-homed.
1091
+ function adoptInto(node, doc) {
1092
+ if (node.ownerDocument === doc) return;
1093
+ if (node.nodeType === ELEMENT_NODE) {
1094
+ if (node.__attrs === undefined && node.__attrIdx >= 0) node.__attrs = node.__buildAttrs();
1095
+ node.__attrIdx = -1;
1096
+ }
1097
+ const kids = node.__children ? node.__children() : []; // inflate off the OLD buffer
1098
+ node.__idx = -1;
1099
+ node.ownerDocument = doc;
1100
+ for (const c of kids) adoptInto(c, doc);
1101
+ }
1102
+
1019
1103
  // --- MutationObserver, wired to the mutation methods above via notifyMutation ---
1020
1104
  function isDescendant(node, ancestor) {
1021
1105
  let n = node;
@@ -1218,7 +1302,7 @@ export class Document extends Node {
1218
1302
  createComment(data) { return new Comment(this, String(data)); }
1219
1303
  getSelection() { if (!this.__selection) this.__selection = makeSelection(); return this.__selection; }
1220
1304
  importNode(node, deep) { return node.cloneNode(deep); }
1221
- adoptNode(node) { if (node.parentNode) node.parentNode.removeChild(node); node.ownerDocument = this; return node; }
1305
+ adoptNode(node) { if (node.parentNode) node.parentNode.removeChild(node); adoptInto(node, this); return node; }
1222
1306
 
1223
1307
  // TreeWalker / NodeIterator (whatToShow: 1 elements, 4 text, 0xFFFFFFFF all)
1224
1308
  createTreeWalker(root, whatToShow = 0xffffffff, filter = null) { return new TreeWalker(root, whatToShow, filter); }
@@ -222,8 +222,14 @@ export class EventTarget {
222
222
  event.eventPhase = PHASE_NONE;
223
223
  event.currentTarget = null;
224
224
 
225
- // canceled activation: undo the pre-click toggle if default was prevented
225
+ // canceled activation: undo the pre-click toggle if default was prevented.
226
+ // Otherwise a checkbox/radio toggle fires input then change (activation
227
+ // default action) — user-event/React rely on these to see the new value.
226
228
  if (activation && event.defaultPrevented) activation.undo();
229
+ else if (activation && activation.fireChange) {
230
+ this.dispatchEvent(new Event('input', { bubbles: true }));
231
+ this.dispatchEvent(new Event('change', { bubbles: true }));
232
+ }
227
233
 
228
234
  // remaining default actions (label→control, form submit) unless prevented
229
235
  if (!event.defaultPrevented && typeof this.__runDefaultAction === 'function') {
@@ -22,11 +22,18 @@ export * from './dom.mjs';
22
22
  // (usually the empty default) → parse once, reuse for every file, skipping the
23
23
  // native parse + boundary marshaling entirely. Bounded (fixtures are few).
24
24
  const __parseCache = new Map();
25
+ const __PARSE_CACHE_MAX = 64;
25
26
  function parseBufferCached(html) {
26
27
  const hit = __parseCache.get(html);
27
- if (hit !== undefined) return hit;
28
+ if (hit !== undefined) {
29
+ // LRU bump: re-insert so this entry is now most-recently-used (Map keeps order)
30
+ __parseCache.delete(html); __parseCache.set(html, hit);
31
+ return hit;
32
+ }
28
33
  const soa = native.parseBuffer(html);
29
- if (__parseCache.size > 64) __parseCache.clear();
34
+ // Evict the single oldest entry, not the whole cache — a suite with >64 distinct
35
+ // shells should keep its hot fixtures warm, not thrash every one cold on overflow.
36
+ if (__parseCache.size >= __PARSE_CACHE_MAX) __parseCache.delete(__parseCache.keys().next().value);
30
37
  __parseCache.set(html, soa);
31
38
  return soa;
32
39
  }
@@ -79,28 +79,8 @@ export function makeMatchMedia() {
79
79
  });
80
80
  }
81
81
 
82
- // getComputedStyle: honestreflects ONLY inline + explicitly-set values, never
83
- // invents cascade/layout numbers. A property that wasn't set reads as ''.
84
- export function makeGetComputedStyle() {
85
- return (el) => {
86
- // The Proxy reads el.style LIVE on each access, so one cached Proxy per
87
- // element is always correct (no version needed) — avoids re-allocating a
88
- // Proxy on every call, which dom-accessibility-api does per element in
89
- // getByRole/getByText visibility checks.
90
- if (el && el.__computedStyle) return el.__computedStyle;
91
- const style = el && el.style ? el.style : null;
92
- const proxy = new Proxy({}, {
93
- get(_t, key) {
94
- if (key === 'getPropertyValue') return (p) => (style ? style.getPropertyValue(p) : '');
95
- if (key === '__honest') return 'computed style is inline-only; no layout/cascade available';
96
- if (typeof key !== 'string') return undefined;
97
- return style ? style[key] : '';
98
- },
99
- });
100
- if (el) el.__computedStyle = proxy;
101
- return proxy;
102
- };
103
- }
82
+ // getComputedStyle lives in cascade.mjs it resolves injected <style> sheets +
83
+ // inline styles (a strict superset of the old inline-only stub once here).
104
84
 
105
85
  class ObserverStub {
106
86
  constructor(cb) { this.__cb = cb; }
@@ -5,11 +5,12 @@
5
5
  // tracer: it records which globals each test actually touches.
6
6
 
7
7
  import {
8
- Storage, makeMatchMedia, makeGetComputedStyle,
8
+ Storage, makeMatchMedia,
9
9
  IntersectionObserver, ResizeObserver,
10
10
  FileReader, makeCanvasStub, makeCustomElements,
11
11
  makeLocation, makeHistory,
12
12
  } from './stubs.mjs';
13
+ import { makeGetComputedStyle } from './cascade.mjs';
13
14
  import {
14
15
  Node, Element, Text, Comment, Document, DocumentFragment, DocumentType, Event, CustomEvent,
15
16
  MutationObserver, DOMParser, XMLSerializer,
@@ -250,6 +251,22 @@ const STATIC_BASE = {
250
251
  // every element is a plain Element → `el instanceof HTMLElement` is true.
251
252
  // Tag-specific interfaces match by localName via Symbol.hasInstance.
252
253
  HTMLElement: Element, SVGElement: Element,
254
+ // SVG interface globals. Libs (and test setup mocks) do
255
+ // `Object.defineProperty(SVGSVGElement.prototype, 'viewBox', …)` and
256
+ // `el instanceof SVGSVGElement`; an undefined global is a hard ReferenceError.
257
+ SVGSVGElement: tagClass('svg'), SVGPathElement: tagClass('path'),
258
+ SVGGElement: tagClass('g'), SVGCircleElement: tagClass('circle'),
259
+ SVGRectElement: tagClass('rect'), SVGLineElement: tagClass('line'),
260
+ SVGEllipseElement: tagClass('ellipse'), SVGPolygonElement: tagClass('polygon'),
261
+ SVGPolylineElement: tagClass('polyline'), SVGTextElement: tagClass('text'),
262
+ SVGTSpanElement: tagClass('tspan'), SVGUseElement: tagClass('use'),
263
+ SVGDefsElement: tagClass('defs'), SVGImageElement: tagClass('image'),
264
+ SVGStopElement: tagClass('stop'), SVGSymbolElement: tagClass('symbol'),
265
+ SVGMarkerElement: tagClass('marker'), SVGClipPathElement: tagClass('clipPath'),
266
+ SVGMaskElement: tagClass('mask'), SVGPatternElement: tagClass('pattern'),
267
+ SVGTitleElement: tagClass('title'), SVGDescElement: tagClass('desc'),
268
+ SVGForeignObjectElement: tagClass('foreignObject'),
269
+ SVGGraphicsElement: Element, SVGGeometryElement: Element, SVGTextContentElement: Element,
253
270
  HTMLAnchorElement: tagClass('a'), HTMLInputElement: tagClass('input'),
254
271
  HTMLTextAreaElement: tagClass('textarea'), HTMLSelectElement: tagClass('select'),
255
272
  HTMLOptionElement: tagClass('option'), HTMLButtonElement: tagClass('button'),
@@ -260,6 +277,31 @@ const STATIC_BASE = {
260
277
  HTMLUListElement: tagClass('ul'), HTMLLIElement: tagClass('li'),
261
278
  HTMLBodyElement: tagClass('body'), HTMLIFrameElement: tagClass('iframe'),
262
279
  HTMLHeadingElement: tagClass(/^h[1-6]$/),
280
+ // Previously-missing interfaces: undefined globals make `el instanceof HTMLXElement`
281
+ // throw "Right-hand side of 'instanceof' is not an object" — worse than returning
282
+ // false. RTL/React probe these constantly.
283
+ HTMLTableElement: tagClass('table'), HTMLTableRowElement: tagClass('tr'),
284
+ HTMLTableCellElement: tagClass(/^(td|th)$/), HTMLTableSectionElement: tagClass(/^(thead|tbody|tfoot)$/),
285
+ HTMLTableColElement: tagClass(/^(col|colgroup)$/), HTMLTableCaptionElement: tagClass('caption'),
286
+ HTMLOListElement: tagClass('ol'), HTMLDListElement: tagClass('dl'),
287
+ HTMLFieldSetElement: tagClass('fieldset'), HTMLLegendElement: tagClass('legend'),
288
+ HTMLOptGroupElement: tagClass('optgroup'), HTMLDataListElement: tagClass('datalist'),
289
+ HTMLOutputElement: tagClass('output'), HTMLProgressElement: tagClass('progress'),
290
+ HTMLMeterElement: tagClass('meter'), HTMLDetailsElement: tagClass('details'),
291
+ HTMLDialogElement: tagClass('dialog'), HTMLPreElement: tagClass('pre'),
292
+ HTMLBRElement: tagClass('br'), HTMLHRElement: tagClass('hr'),
293
+ HTMLQuoteElement: tagClass(/^(q|blockquote)$/), HTMLModElement: tagClass(/^(ins|del)$/),
294
+ HTMLPictureElement: tagClass('picture'), HTMLSourceElement: tagClass('source'),
295
+ HTMLTrackElement: tagClass('track'), HTMLVideoElement: tagClass('video'),
296
+ HTMLAudioElement: tagClass('audio'), HTMLMediaElement: tagClass(/^(video|audio)$/),
297
+ HTMLEmbedElement: tagClass('embed'), HTMLObjectElement: tagClass('object'),
298
+ HTMLMapElement: tagClass('map'), HTMLAreaElement: tagClass('area'),
299
+ HTMLScriptElement: tagClass('script'), HTMLStyleElement: tagClass('style'),
300
+ HTMLLinkElement: tagClass('link'), HTMLMetaElement: tagClass('meta'),
301
+ HTMLTitleElement: tagClass('title'), HTMLBaseElement: tagClass('base'),
302
+ HTMLHeadElement: tagClass('head'), HTMLHtmlElement: tagClass('html'),
303
+ HTMLDataElement: tagClass('data'), HTMLTimeElement: tagClass('time'),
304
+ HTMLSlotElement: tagClass('slot'), HTMLMenuElement: tagClass('menu'),
263
305
  HTMLDocument: Document, ShadowRoot: DocumentFragment,
264
306
  MutationObserver, DOMParser, XMLSerializer,
265
307
  URL: TURBO_URL, URLSearchParams,