@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 +1 -1
- package/src/runtime/cascade.mjs +182 -0
- package/src/runtime/dom.mjs +97 -13
- package/src/runtime/events.mjs +7 -1
- package/src/runtime/index.mjs +9 -2
- package/src/runtime/stubs.mjs +2 -22
- package/src/runtime/window.mjs +43 -1
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.1.
|
|
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
|
+
}
|
package/src/runtime/dom.mjs
CHANGED
|
@@ -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>
|
|
457
|
-
//
|
|
458
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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); }
|
package/src/runtime/events.mjs
CHANGED
|
@@ -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') {
|
package/src/runtime/index.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
}
|
package/src/runtime/stubs.mjs
CHANGED
|
@@ -79,28 +79,8 @@ export function makeMatchMedia() {
|
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// getComputedStyle
|
|
83
|
-
//
|
|
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; }
|
package/src/runtime/window.mjs
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
// tracer: it records which globals each test actually touches.
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
-
Storage, makeMatchMedia,
|
|
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,
|
|
Binary file
|