@miaskiewicz/turbo-dom 0.1.31 → 0.1.33
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/README.md +34 -15
- package/package.json +3 -1
- package/src/runtime/cascade.mjs +99 -16
- package/src/runtime/collections.mjs +5 -1
- package/src/runtime/dom.mjs +217 -31
- package/src/runtime/events.mjs +67 -6
- package/src/runtime/index.mjs +24 -2
- package/src/runtime/selectors.mjs +0 -5
- package/src/runtime/window.mjs +3 -2
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
package/README.md
CHANGED
|
@@ -14,9 +14,11 @@ npm install -D @miaskiewicz/turbo-dom
|
|
|
14
14
|
|
|
15
15
|
- ✅ **More compatible than happy-dom** — 99.72% on html5lib-tests vs happy-dom's 37%.
|
|
16
16
|
Runs React Testing Library, `user-event`, downshift, Radix UI, and Headless UI unmodified.
|
|
17
|
-
- ⚡ **Faster than both** — ~
|
|
18
|
-
- 🎯 **Honest, not lying** — no fake layout numbers; `getBoundingClientRect()` is zeros
|
|
19
|
-
`getComputedStyle`
|
|
17
|
+
- ⚡ **Faster than both** — ~130× jsdom / ~45× happy-dom on a realistic suite (parse-memoized repeated shells), ~8–35× faster HTML parsing on real pages, and ~2.2× happy-dom on repeated queries while staying 99.7% spec-correct.
|
|
18
|
+
- 🎯 **Honest, not lying** — no fake layout numbers; `getBoundingClientRect()` is zeros.
|
|
19
|
+
`getComputedStyle` runs a **partial cascade**: it resolves real injected `<style>` rules
|
|
20
|
+
(emotion/MUI `.css-HASH{…}`) + inline styles with proper specificity/source order — but only
|
|
21
|
+
ever returns values a real rule set, never invented layout. Geometry tests belong in a real browser.
|
|
20
22
|
|
|
21
23
|
## Quick start
|
|
22
24
|
|
|
@@ -90,7 +92,9 @@ parseFragment('<rect/>', 'svg path'); // fragment in a context elem
|
|
|
90
92
|
| html5lib-tests conformance | **99.72%** | 37.35% | 97.03% |
|
|
91
93
|
| @testing-library/dom + user-event | ✅ | ✅ | ✅ |
|
|
92
94
|
| React + Radix / Headless UI / downshift | ✅ | partial | ✅ |
|
|
93
|
-
| Real layout
|
|
95
|
+
| Real layout | ❌ (honest stub) | partial | partial |
|
|
96
|
+
| `getComputedStyle` cascade | partial (real `<style>` + inline) | partial | partial |
|
|
97
|
+
| Shadow DOM (attach, slots, event retargeting, scoped/`:host` CSS) | ✅ | ✅ | ✅ |
|
|
94
98
|
|
|
95
99
|
turbo-dom inherits Servo's tree constructor, so the "messy input" cases hand-rolled parsers
|
|
96
100
|
get wrong — adoption-agency reparenting (`<a><p></a></p>`), table foster-parenting, optional
|
|
@@ -105,16 +109,17 @@ the suite row (ms/file, lower = faster):
|
|
|
105
109
|
|
|
106
110
|
| benchmark | turbo-dom | happy-dom | jsdom |
|
|
107
111
|
|---|---:|---:|---:|
|
|
108
|
-
| **realistic suite**, 200 files (ms/file) |
|
|
109
|
-
| **per-file
|
|
110
|
-
| **parse 56 KB SSR** (ops/s) | **
|
|
111
|
-
| **parse 20 KB real page** (ops/s) | **
|
|
112
|
-
| repeated query throughput (iters/s) | **
|
|
112
|
+
| **realistic suite**, 200 files (ms/file) | **~0.025** | 1.13 | 3.40 |
|
|
113
|
+
| **cold per-file construct + query** (ops/s) | **~200k** | 579 | 266 |
|
|
114
|
+
| **parse 56 KB SSR** (ops/s) | **510** | 48 | 66 |
|
|
115
|
+
| **parse 20 KB real page** (ops/s) | **3,628** | 161 | 105 |
|
|
116
|
+
| repeated query throughput (iters/s) | **~1.5M** | 680k | 3.3k |
|
|
113
117
|
| html5lib conformance | **99.72%** | 37.35% | 97.03% |
|
|
114
118
|
|
|
115
119
|
On a realistic suite — 200 files of construct + queries + events — turbo-dom is
|
|
116
|
-
**~
|
|
117
|
-
|
|
120
|
+
**~45× happy-dom and ~130× jsdom**, runs repeated queries **~2.2× happy-dom**
|
|
121
|
+
(~460× jsdom), and parses real pages/SSR documents **~8–35×** faster, all at 99.7%
|
|
122
|
+
conformance.
|
|
118
123
|
|
|
119
124
|
The per-file setup number is so high because the parser **memoizes the read-only
|
|
120
125
|
SoA buffer by HTML string**: a suite calls the env setup with the same document
|
|
@@ -123,7 +128,7 @@ go to per-Document overlays) is reused. The first parse of a given shell pays fu
|
|
|
123
128
|
cost (the parse rows above); every reuse is near-free.
|
|
124
129
|
|
|
125
130
|
**turbo-dom wins across the board on what test suites actually do**: per-file
|
|
126
|
-
construction (~
|
|
131
|
+
construction (~45× happy-dom, ~130× jsdom on a repeated-shell suite), parsing,
|
|
127
132
|
spec-correctness (99.7% vs 37%), **and** repeated queries.
|
|
128
133
|
|
|
129
134
|
How the query speed holds up against happy-dom (whose whole design trades correctness
|
|
@@ -151,10 +156,24 @@ JS (chatty, fine-grained) but pays only for what a test touches. Full design not
|
|
|
151
156
|
## Limitations (by design)
|
|
152
157
|
|
|
153
158
|
- **No layout.** `getBoundingClientRect()` returns zeros; `getClientRects()` is empty.
|
|
154
|
-
- **`getComputedStyle` is
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
- **`getComputedStyle` is a partial cascade** — it resolves REAL injected `<style>` rules
|
|
160
|
+
(emotion/MUI `.css-HASH{…}`) plus inline `style`, applying specificity and source order
|
|
161
|
+
(inline wins). It expands common shorthands to longhands (`margin`/`padding`/`border`/single-token
|
|
162
|
+
`background`), serializes bare `0` as `0px` for length props, and normalizes `font-family`
|
|
163
|
+
comma spacing to match browser output. Out of scope (returns `''`): `@media`/`@supports`/
|
|
164
|
+
`@keyframes`, `:hover`/state pseudo-classes, pseudo-elements, full inheritance, CSS custom
|
|
165
|
+
properties, and length-unit conversion (`em`/`rem`→`px`). Only ever returns values from a
|
|
166
|
+
matched rule or inline declaration — never an invented one. Style/geometry assertions belong
|
|
167
|
+
in a real browser (Playwright/WebDriver).
|
|
157
168
|
- Canvas, `<select>` rendering, and similar visual APIs are honest no-op stubs.
|
|
169
|
+
- **Shadow DOM** is supported and pay-for-what-you-use — every event/query/cascade hot path
|
|
170
|
+
is unchanged until the first `attachShadow` flips a per-document flag. Covered: `attachShadow`
|
|
171
|
+
(open/closed), encapsulated `querySelector`/`getElementById`, `getRootNode({composed})`,
|
|
172
|
+
full event propagation with `target`/`relatedTarget` retargeting and `composed` boundary
|
|
173
|
+
crossing, `<slot>` `assignedNodes`/`assignedElements`/`assignedSlot`, scoped `getComputedStyle`
|
|
174
|
+
with `:host`/`:host(...)`/`::slotted(...)` and inheritance across the boundary, and declarative
|
|
175
|
+
`<template shadowrootmode>` promotion. Out of scope (honest): flattened-tree layout, `slotchange`
|
|
176
|
+
events, and the cascade caveats above (`@media`/state/pseudo-elements) inside shadow trees.
|
|
158
177
|
|
|
159
178
|
## Development
|
|
160
179
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
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",
|
|
@@ -56,8 +56,10 @@
|
|
|
56
56
|
"build:wasm": "cargo build --release --no-default-features --features wasm-bind --target wasm32-unknown-unknown",
|
|
57
57
|
"build:wasm:pkg": "wasm-pack build --target nodejs --out-dir pkg --no-default-features --features wasm-bind",
|
|
58
58
|
"test": "node --test 'test/*.mjs'",
|
|
59
|
+
"test:cov": "node --test --experimental-test-coverage --test-coverage-include='src/runtime/**' --test-coverage-lines=99 --test-coverage-functions=87 --test-coverage-branches=87 'test/*.mjs'",
|
|
59
60
|
"test:rust": "cargo test --lib",
|
|
60
61
|
"test:all": "npm run build && npm run test:rust && npm test",
|
|
62
|
+
"prepare": "git config core.hooksPath .githooks || true",
|
|
61
63
|
"conformance": "node harness/conformance.mjs",
|
|
62
64
|
"conformance:delta": "node harness/delta.mjs",
|
|
63
65
|
"bench": "node bench/parse.mjs",
|
package/src/runtime/cascade.mjs
CHANGED
|
@@ -106,35 +106,77 @@ function parseStylesheet(css, startOrder, rules) {
|
|
|
106
106
|
return order;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
function buildIndex(
|
|
110
|
-
|
|
109
|
+
function buildIndex(scope) {
|
|
110
|
+
// scope is always a Document or ShadowRoot — both expose getElementsByTagName.
|
|
111
|
+
const styles = scope.getElementsByTagName('style');
|
|
111
112
|
const rules = [];
|
|
112
113
|
let order = 0;
|
|
113
114
|
for (let i = 0; i < styles.length; i++) order = parseStylesheet(styles[i].textContent || '', order, rules);
|
|
114
115
|
return rules;
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
// `scope` is the Document (light DOM) or a ShadowRoot (encapsulated). Both hold
|
|
119
|
+
// their own __styleIndex; the version key is the Document version (shadow style
|
|
120
|
+
// mutations bump it too), so either cache auto-invalidates on any mutation.
|
|
121
|
+
function getIndex(scope, v) {
|
|
122
|
+
const cached = scope.__styleIndex;
|
|
119
123
|
if (cached && cached.v === v) return cached.rules;
|
|
120
|
-
const rules = buildIndex(
|
|
121
|
-
|
|
124
|
+
const rules = buildIndex(scope);
|
|
125
|
+
scope.__styleIndex = { v, rules };
|
|
122
126
|
return rules;
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
const HOST_RE = /^:host(?:\((.*)\))?$/; // :host or :host(sel)
|
|
130
|
+
const SLOTTED_RE = /::slotted\(([^)]*)\)/; // ::slotted(sel)
|
|
131
|
+
|
|
132
|
+
// Collect rules from one index that apply to `el` under a given `kind`:
|
|
133
|
+
// 'normal' — el matched against the selector (skips :host/::slotted rules)
|
|
134
|
+
// 'host' — el is a shadow host; match the inner of a :host[(sel)] rule
|
|
135
|
+
// 'slotted' — el is a slotted light node; match the inner of ::slotted(sel)
|
|
136
|
+
function collectMatched(el, rules, kind, into) {
|
|
128
137
|
for (let i = 0; i < rules.length; i++) {
|
|
129
138
|
const r = rules[i];
|
|
130
|
-
|
|
139
|
+
const sel = r.selector;
|
|
140
|
+
const hostM = HOST_RE.exec(sel);
|
|
141
|
+
const slotM = SLOTTED_RE.exec(sel);
|
|
142
|
+
if (kind === 'normal') {
|
|
143
|
+
if (hostM || slotM) continue; // shadow-only selectors never match plain elements
|
|
144
|
+
try { if (matchesSelector(el, sel)) into.push(r); } catch { /* unsupported → skip */ }
|
|
145
|
+
} else if (kind === 'host') {
|
|
146
|
+
if (!hostM) continue;
|
|
147
|
+
const inner = hostM[1];
|
|
148
|
+
if (inner === undefined) into.push(r);
|
|
149
|
+
else try { if (matchesSelector(el, inner.trim())) into.push(r); } catch { /* skip */ }
|
|
150
|
+
} else { // slotted
|
|
151
|
+
if (!slotM) continue;
|
|
152
|
+
const inner = (slotM[1] || '*').trim() || '*';
|
|
153
|
+
try { if (matchesSelector(el, inner)) into.push(r); } catch { /* skip */ }
|
|
154
|
+
}
|
|
131
155
|
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Inheritable properties we propagate across the shadow boundary into shadow
|
|
159
|
+
// content (curated; matches the common inherited set). Honest partial: only
|
|
160
|
+
// these inherit, and only INTO shadow trees — light DOM stays inheritance-free.
|
|
161
|
+
const INHERITED = new Set([
|
|
162
|
+
'color', 'cursor', 'direction', 'font', 'font-family', 'font-size', 'font-style',
|
|
163
|
+
'font-variant', 'font-weight', 'letter-spacing', 'line-height', 'list-style',
|
|
164
|
+
'list-style-type', 'text-align', 'text-indent', 'text-transform', 'visibility',
|
|
165
|
+
'white-space', 'word-spacing', 'quotes',
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
// Flattened-tree parent for inheritance: the DOM parent, hopping shadow-root→host
|
|
169
|
+
// at the boundary; null if there is no element parent.
|
|
170
|
+
function flattenedParent(el) {
|
|
171
|
+
const p = el.parentNode;
|
|
172
|
+
if (!p) return null;
|
|
173
|
+
if (p.__isShadowRoot) return p.host;
|
|
174
|
+
return p.nodeType === 1 ? p : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function applyMatched(matched, out) {
|
|
132
178
|
matched.sort((x, y) => x.spec - y.spec || x.order - y.order);
|
|
133
179
|
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
180
|
}
|
|
139
181
|
|
|
140
182
|
function lookup(map, prop) {
|
|
@@ -172,15 +214,56 @@ function makeProxy(map, v) {
|
|
|
172
214
|
// getComputedStyle(el): resolves cascade, memoized per element on the current
|
|
173
215
|
// Document.__version (any style/DOM mutation bumps it → cache auto-invalidates).
|
|
174
216
|
export function makeGetComputedStyle() {
|
|
175
|
-
|
|
217
|
+
const gcs = (el) => {
|
|
176
218
|
if (!el) return makeProxy(new Map(), -1);
|
|
177
219
|
const doc = el.ownerDocument;
|
|
178
220
|
const v = doc ? (doc.__version || 0) : 0;
|
|
179
221
|
const cached = el.__computedStyle;
|
|
180
222
|
if (cached && cached.__v === v) return cached;
|
|
181
|
-
|
|
223
|
+
|
|
224
|
+
const map = new Map();
|
|
225
|
+
if (!doc) { const p = makeProxy(map, v); el.__computedStyle = p; return p; }
|
|
226
|
+
|
|
227
|
+
// Encapsulation: an element inside a shadow tree resolves against that
|
|
228
|
+
// shadow root's own <style> rules, not the document's (and vice-versa).
|
|
229
|
+
// Gated on __hasShadow so the no-shadow path keeps resolving against doc.
|
|
230
|
+
let scope = doc, inShadow = false;
|
|
231
|
+
if (doc.__hasShadow) {
|
|
232
|
+
const root = el.getRootNode && el.getRootNode();
|
|
233
|
+
if (root && root.__isShadowRoot) { scope = root; inShadow = true; }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const matched = [];
|
|
237
|
+
collectMatched(el, getIndex(scope, v), 'normal', matched);
|
|
238
|
+
if (doc.__hasShadow) {
|
|
239
|
+
// shadow host: overlay :host rules from its OWN shadow root's stylesheet
|
|
240
|
+
if (el.__shadow) collectMatched(el, getIndex(el.__shadow, v), 'host', matched);
|
|
241
|
+
// slotted light node: overlay ::slotted rules from the slot's shadow root
|
|
242
|
+
const slot = el.assignedSlot;
|
|
243
|
+
if (slot) { const sr = slot.getRootNode(); if (sr.__isShadowRoot) collectMatched(el, getIndex(sr, v), 'slotted', matched); }
|
|
244
|
+
}
|
|
245
|
+
applyMatched(matched, map);
|
|
246
|
+
|
|
247
|
+
// inline style wins over stylesheet rules
|
|
248
|
+
const inline = el.getAttribute && el.getAttribute('style');
|
|
249
|
+
if (inline) parseDecls(inline, map);
|
|
250
|
+
|
|
251
|
+
// Inheritance INTO shadow content only: an element inside a shadow tree
|
|
252
|
+
// inherits unset inheritable props from its flattened parent (crossing the
|
|
253
|
+
// host boundary). Light-DOM elements stay inheritance-free (honest).
|
|
254
|
+
if (inShadow) {
|
|
255
|
+
const parent = flattenedParent(el);
|
|
256
|
+
if (parent) {
|
|
257
|
+
const ps = gcs(parent); // recursive, memoized, terminates at the light-DOM host
|
|
258
|
+
for (const prop of INHERITED) {
|
|
259
|
+
if (!map.has(prop)) { const pv = ps.getPropertyValue(prop); if (pv) map.set(prop, pv); }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
182
264
|
const proxy = makeProxy(map, v);
|
|
183
265
|
el.__computedStyle = proxy;
|
|
184
266
|
return proxy;
|
|
185
267
|
};
|
|
268
|
+
return gcs;
|
|
186
269
|
}
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
// reflect mutations immediately — the exact place happy-dom bleeds liveness bugs.
|
|
3
3
|
|
|
4
4
|
function makeLive(getArray, extra = {}) {
|
|
5
|
-
|
|
5
|
+
// Plain-object target: it has no non-configurable own keys, so ownKeys can
|
|
6
|
+
// report indices + length without tripping the proxy invariant (a function
|
|
7
|
+
// target carries a non-configurable `prototype`, which made Object.keys(coll)
|
|
8
|
+
// throw and `typeof coll` wrongly 'function' — HTMLCollection is an object).
|
|
9
|
+
const target = {};
|
|
6
10
|
return new Proxy(target, {
|
|
7
11
|
get(_t, key) {
|
|
8
12
|
if (typeof key === 'string') {
|
package/src/runtime/dom.mjs
CHANGED
|
@@ -181,11 +181,24 @@ export class Node extends EventTarget {
|
|
|
181
181
|
|
|
182
182
|
get isConnected() {
|
|
183
183
|
let n = this;
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
for (;;) {
|
|
185
|
+
while (n.parentNode) n = n.parentNode;
|
|
186
|
+
// a node inside a shadow tree is connected iff its host is connected
|
|
187
|
+
if (n.__isShadowRoot) { n = n.host; continue; }
|
|
188
|
+
return n.nodeType === DOCUMENT_NODE;
|
|
189
|
+
}
|
|
186
190
|
}
|
|
187
|
-
getRootNode() {
|
|
191
|
+
getRootNode(options) {
|
|
188
192
|
let n = this;
|
|
193
|
+
if (options && options.composed) {
|
|
194
|
+
// climb through shadow boundaries to the topmost root (document/detached)
|
|
195
|
+
for (;;) {
|
|
196
|
+
while (n.parentNode) n = n.parentNode;
|
|
197
|
+
if (n.__isShadowRoot) { n = n.host; continue; }
|
|
198
|
+
return n;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// non-composed: stop at the enclosing shadow root if there is one
|
|
189
202
|
while (n.parentNode) n = n.parentNode;
|
|
190
203
|
return n;
|
|
191
204
|
}
|
|
@@ -245,6 +258,10 @@ export class Node extends EventTarget {
|
|
|
245
258
|
// insertBefore/removeChild.
|
|
246
259
|
__touch() { const d = this.ownerDocument; if (d) d.__version = (d.__version || 0) + 1; }
|
|
247
260
|
|
|
261
|
+
// Element/Document/Fragment nodeValue is null per spec (CharacterData overrides).
|
|
262
|
+
get nodeValue() { return null; }
|
|
263
|
+
set nodeValue(_v) { /* no-op for non-CharacterData nodes, per spec */ }
|
|
264
|
+
|
|
248
265
|
// window/document path for event propagation past the document
|
|
249
266
|
get __owner() { return null; }
|
|
250
267
|
}
|
|
@@ -454,7 +471,11 @@ export class Element extends Node {
|
|
|
454
471
|
return list.length && !this.multiple ? list[0].value : '';
|
|
455
472
|
}
|
|
456
473
|
if (t === 'option') return this.hasAttribute('value') ? this.getAttribute('value') : this.textContent;
|
|
457
|
-
|
|
474
|
+
// textarea has no value attribute — its raw value defaults to the child text
|
|
475
|
+
// content (the WHATWG "default value") until edited; input falls back to the
|
|
476
|
+
// value attribute (defaultValue).
|
|
477
|
+
if (t === 'textarea') return this.__value !== undefined ? this.__value : this.textContent;
|
|
478
|
+
if (t === 'input') return this.__value !== undefined ? this.__value : (this.getAttribute('value') ?? '');
|
|
458
479
|
return undefined;
|
|
459
480
|
}
|
|
460
481
|
set value(x) {
|
|
@@ -786,18 +807,47 @@ export class Element extends Node {
|
|
|
786
807
|
getContext(type) { return this.localName === 'canvas' ? (this.__ctx ||= makeCanvasStub()) : null; }
|
|
787
808
|
toDataURL() { return 'data:,'; }
|
|
788
809
|
|
|
789
|
-
// shadow DOM
|
|
810
|
+
// shadow DOM. Flipping doc.__hasShadow arms the gated slow paths (event
|
|
811
|
+
// retargeting in events.mjs, scoped cascade in cascade.mjs); before the first
|
|
812
|
+
// attachShadow every benchmarked path runs exactly as it did with no shadow code.
|
|
790
813
|
attachShadow(init = {}) {
|
|
791
|
-
|
|
792
|
-
root
|
|
793
|
-
root.mode = init.mode || 'open';
|
|
794
|
-
root.querySelector = (s) => qsel(root, s);
|
|
795
|
-
root.querySelectorAll = (s) => qselAll(root, s);
|
|
814
|
+
if (this.__shadow) throw new Error('NotSupportedError: shadow root already attached');
|
|
815
|
+
const root = new ShadowRoot(this, init.mode || 'open', init.delegatesFocus);
|
|
796
816
|
this.__shadow = root;
|
|
797
817
|
if (root.mode === 'open') this.shadowRoot = root;
|
|
818
|
+
const doc = this.ownerDocument;
|
|
819
|
+
if (doc) doc.__hasShadow = true;
|
|
798
820
|
return root;
|
|
799
821
|
}
|
|
800
822
|
|
|
823
|
+
// ---- <slot> projection (all on-demand; never touched by parse/query/events) ----
|
|
824
|
+
// A light-DOM child's assigned slot: the matching <slot> in its parent host's
|
|
825
|
+
// shadow tree, or null when the parent isn't a shadow host / no slot matches.
|
|
826
|
+
get assignedSlot() {
|
|
827
|
+
const host = this.parentNode;
|
|
828
|
+
if (!host || !host.__shadow) return null;
|
|
829
|
+
return findSlot(host.__shadow, this.getAttribute('slot') || '');
|
|
830
|
+
}
|
|
831
|
+
// <slot>.assignedNodes(): the host's light children routed to this slot by name
|
|
832
|
+
// (default slot ← unnamed children + text). With {flatten:true} an empty slot
|
|
833
|
+
// falls back to its own children (the slot's default content).
|
|
834
|
+
assignedNodes(options) {
|
|
835
|
+
if (this.localName !== 'slot') return [];
|
|
836
|
+
const root = this.getRootNode();
|
|
837
|
+
if (!root.__isShadowRoot) return [];
|
|
838
|
+
const slotName = this.getAttribute('name') || '';
|
|
839
|
+
const out = [];
|
|
840
|
+
for (const c of root.host.__children()) {
|
|
841
|
+
if (c.nodeType === ELEMENT_NODE) { if ((c.getAttribute('slot') || '') === slotName) out.push(c); }
|
|
842
|
+
else if (c.nodeType === TEXT_NODE && slotName === '') out.push(c); // text routes only to the default slot
|
|
843
|
+
}
|
|
844
|
+
if (out.length === 0 && options && options.flatten) {
|
|
845
|
+
return this.__children().filter((n) => n.nodeType === ELEMENT_NODE || n.nodeType === TEXT_NODE);
|
|
846
|
+
}
|
|
847
|
+
return out;
|
|
848
|
+
}
|
|
849
|
+
assignedElements(options) { return this.assignedNodes(options).filter((n) => n.nodeType === ELEMENT_NODE); }
|
|
850
|
+
|
|
801
851
|
// forms
|
|
802
852
|
get form() { return this.closest ? this.closest('form') : null; }
|
|
803
853
|
get elements() {
|
|
@@ -837,9 +887,73 @@ export class DocumentFragment extends Node {
|
|
|
837
887
|
cloneNode(deep = false) { const f = new DocumentFragment(this.ownerDocument); if (deep) for (const c of this.__children()) f.appendChild(c.cloneNode(true)); return f; }
|
|
838
888
|
}
|
|
839
889
|
|
|
890
|
+
// A real shadow root: a detached document-fragment subtree with a back-reference
|
|
891
|
+
// to its host element. `__isShadowRoot` is the duck-typed flag events.mjs and
|
|
892
|
+
// Node.getRootNode/isConnected branch on (no import cycle). Encapsulation is free
|
|
893
|
+
// — the host's __children() never includes this subtree, so querySelector /
|
|
894
|
+
// getElementsBy* / matching never reach in.
|
|
895
|
+
export class ShadowRoot extends DocumentFragment {
|
|
896
|
+
constructor(host, mode = 'open', delegatesFocus = false) {
|
|
897
|
+
super(host.ownerDocument);
|
|
898
|
+
this.host = host;
|
|
899
|
+
this.mode = mode;
|
|
900
|
+
this.delegatesFocus = !!delegatesFocus;
|
|
901
|
+
this.__isShadowRoot = true;
|
|
902
|
+
}
|
|
903
|
+
get nodeName() { return '#document-fragment'; }
|
|
904
|
+
get innerHTML() { return serializeInner(this); }
|
|
905
|
+
set innerHTML(html) {
|
|
906
|
+
const frag = native.parseFragment(String(html), ''); // empty context → body
|
|
907
|
+
this.__kids = [];
|
|
908
|
+
this.__touch();
|
|
909
|
+
for (const rawChild of frag.children) {
|
|
910
|
+
if (rawChild.nodeType === DOCUMENT_FRAGMENT_NODE && rawChild.name === 'content') continue;
|
|
911
|
+
const child = this.ownerDocument.__inflateNested(rawChild);
|
|
912
|
+
child.parentNode = this;
|
|
913
|
+
this.__kids.push(child);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
getElementById(id) {
|
|
917
|
+
let found = null;
|
|
918
|
+
const visit = (node) => {
|
|
919
|
+
const kids = node.__children();
|
|
920
|
+
for (let i = 0; i < kids.length; i++) {
|
|
921
|
+
const c = kids[i];
|
|
922
|
+
if (c.nodeType !== ELEMENT_NODE) continue;
|
|
923
|
+
if (c.getAttribute('id') === id) { found = c; return; }
|
|
924
|
+
visit(c);
|
|
925
|
+
if (found) return;
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
visit(this);
|
|
929
|
+
return found;
|
|
930
|
+
}
|
|
931
|
+
getElementsByTagName(tag) { const self = this; return liveHTMLCollection(() => collectByTag(self, tag.toLowerCase())); }
|
|
932
|
+
getElementsByClassName(cls) { const self = this; const classes = splitClasses(cls); return liveHTMLCollection(() => collectByClass(self, classes)); }
|
|
933
|
+
// the topmost root of any node within is this shadow root, never the document
|
|
934
|
+
get activeElement() { return null; }
|
|
935
|
+
}
|
|
936
|
+
|
|
840
937
|
// ------------------------------------------------------------ helpers ----
|
|
841
938
|
function toNode(doc, n) { return typeof n === 'string' ? doc.createTextNode(n) : n; }
|
|
842
939
|
|
|
940
|
+
// First <slot> within a shadow tree whose name matches (default slot: name '').
|
|
941
|
+
function findSlot(shadowRoot, name) {
|
|
942
|
+
let found = null;
|
|
943
|
+
const visit = (node) => {
|
|
944
|
+
const kids = node.__children();
|
|
945
|
+
for (let i = 0; i < kids.length; i++) {
|
|
946
|
+
const c = kids[i];
|
|
947
|
+
if (c.nodeType !== ELEMENT_NODE) continue;
|
|
948
|
+
if (c.localName === 'slot' && (c.getAttribute('name') || '') === name) { found = c; return; }
|
|
949
|
+
visit(c);
|
|
950
|
+
if (found) return;
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
visit(shadowRoot);
|
|
954
|
+
return found;
|
|
955
|
+
}
|
|
956
|
+
|
|
843
957
|
function collectByTag(root, tag) {
|
|
844
958
|
const out = [];
|
|
845
959
|
const all = tag === '*';
|
|
@@ -889,7 +1003,16 @@ function zeroRect() {
|
|
|
889
1003
|
return { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() { return this; } };
|
|
890
1004
|
}
|
|
891
1005
|
|
|
892
|
-
//
|
|
1006
|
+
// child index of `node` within its parent (per spec: the node's "index")
|
|
1007
|
+
function nodeIndex(node) {
|
|
1008
|
+
const p = node.parentNode;
|
|
1009
|
+
if (!p) return 0;
|
|
1010
|
+
return p.__children().indexOf(node);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Range — functional DOM Range. Tree-mutating ops (extract/clone/delete/insert/
|
|
1014
|
+
// surround) implement the common case (a single container with element/text
|
|
1015
|
+
// children, offsets as child indices). Zero geometry (no layout).
|
|
893
1016
|
class Range {
|
|
894
1017
|
constructor(doc) {
|
|
895
1018
|
this.__doc = doc;
|
|
@@ -898,42 +1021,106 @@ class Range {
|
|
|
898
1021
|
}
|
|
899
1022
|
setStart(node, offset) { this.startContainer = node; this.startOffset = offset; this.__sync(); }
|
|
900
1023
|
setEnd(node, offset) { this.endContainer = node; this.endOffset = offset; this.__sync(); }
|
|
901
|
-
setStartBefore(node) { this.setStart(node.parentNode,
|
|
902
|
-
setStartAfter(node) { this.setStart(node.parentNode,
|
|
903
|
-
setEndBefore(node) { this.setEnd(node.parentNode,
|
|
904
|
-
setEndAfter(node) { this.setEnd(node.parentNode,
|
|
905
|
-
selectNode
|
|
906
|
-
|
|
1024
|
+
setStartBefore(node) { this.setStart(node.parentNode, nodeIndex(node)); }
|
|
1025
|
+
setStartAfter(node) { this.setStart(node.parentNode, nodeIndex(node) + 1); }
|
|
1026
|
+
setEndBefore(node) { this.setEnd(node.parentNode, nodeIndex(node)); }
|
|
1027
|
+
setEndAfter(node) { this.setEnd(node.parentNode, nodeIndex(node) + 1); }
|
|
1028
|
+
// selectNode: container is the node's parent, offsets bracket the node.
|
|
1029
|
+
selectNode(node) { const p = node.parentNode, i = nodeIndex(node); this.startContainer = this.endContainer = p; this.startOffset = i; this.endOffset = i + 1; this.__sync(); }
|
|
1030
|
+
selectNodeContents(node) { this.startContainer = this.endContainer = node; this.startOffset = 0; this.endOffset = node.nodeType === TEXT_NODE || node.nodeType === COMMENT_NODE ? node.length : node.__children().length; this.__sync(); }
|
|
907
1031
|
collapse(toStart) { if (toStart) { this.endContainer = this.startContainer; this.endOffset = this.startOffset; } else { this.startContainer = this.endContainer; this.startOffset = this.endOffset; } this.collapsed = true; }
|
|
908
1032
|
__sync() { this.collapsed = this.startContainer === this.endContainer && this.startOffset === this.endOffset; }
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1033
|
+
// common ancestor: walk up from start until it contains end (per spec).
|
|
1034
|
+
get commonAncestorContainer() {
|
|
1035
|
+
let container = this.startContainer;
|
|
1036
|
+
while (container && !(container === this.endContainer || (container.contains && container.contains(this.endContainer)))) {
|
|
1037
|
+
container = container.parentNode;
|
|
1038
|
+
}
|
|
1039
|
+
return container || this.startContainer;
|
|
1040
|
+
}
|
|
1041
|
+
cloneRange() { const r = new Range(this.__doc); r.startContainer = this.startContainer; r.endContainer = this.endContainer; r.startOffset = this.startOffset; r.endOffset = this.endOffset; r.collapsed = this.collapsed; return r; }
|
|
1042
|
+
// The common single-container case: start === end container, offsets are child
|
|
1043
|
+
// indices (element children) or string offsets (a text/comment container).
|
|
1044
|
+
__sameContainerKids() { return this.startContainer === this.endContainer; }
|
|
1045
|
+
cloneContents() {
|
|
1046
|
+
const frag = this.__doc.createDocumentFragment();
|
|
1047
|
+
const c = this.startContainer;
|
|
1048
|
+
if (this.__sameContainerKids() && c && c.nodeType !== TEXT_NODE && c.nodeType !== COMMENT_NODE && c.__children) {
|
|
1049
|
+
const kids = c.__children();
|
|
1050
|
+
for (let i = this.startOffset; i < this.endOffset && i < kids.length; i++) frag.appendChild(kids[i].cloneNode(true));
|
|
1051
|
+
}
|
|
1052
|
+
return frag;
|
|
1053
|
+
}
|
|
1054
|
+
extractContents() {
|
|
1055
|
+
const frag = this.__doc.createDocumentFragment();
|
|
1056
|
+
const c = this.startContainer;
|
|
1057
|
+
if (this.__sameContainerKids() && c && c.nodeType !== TEXT_NODE && c.nodeType !== COMMENT_NODE && c.__children) {
|
|
1058
|
+
const kids = c.__children().slice(this.startOffset, this.endOffset);
|
|
1059
|
+
for (const k of kids) frag.appendChild(k); // moves out of the tree
|
|
1060
|
+
}
|
|
1061
|
+
this.collapse(true);
|
|
1062
|
+
return frag;
|
|
1063
|
+
}
|
|
1064
|
+
deleteContents() {
|
|
1065
|
+
const c = this.startContainer;
|
|
1066
|
+
if (this.__sameContainerKids() && c && c.nodeType !== TEXT_NODE && c.nodeType !== COMMENT_NODE && c.__children) {
|
|
1067
|
+
const kids = c.__children().slice(this.startOffset, this.endOffset);
|
|
1068
|
+
for (const k of kids) c.removeChild(k);
|
|
1069
|
+
}
|
|
1070
|
+
this.collapse(true);
|
|
1071
|
+
}
|
|
1072
|
+
insertNode(node) { if (this.startContainer && this.startContainer.insertBefore) this.startContainer.insertBefore(node, this.startContainer.__children ? (this.startContainer.__children()[this.startOffset] ?? null) : null); }
|
|
1073
|
+
surroundContents(node) {
|
|
1074
|
+
const frag = this.extractContents();
|
|
1075
|
+
this.insertNode(node);
|
|
1076
|
+
node.appendChild(frag);
|
|
1077
|
+
}
|
|
1078
|
+
// textual content of the range: the common single-container case.
|
|
1079
|
+
toString() {
|
|
1080
|
+
const c = this.startContainer;
|
|
1081
|
+
if (c && (c.nodeType === TEXT_NODE || c.nodeType === COMMENT_NODE)) {
|
|
1082
|
+
if (this.__sameContainerKids()) return c.data.slice(this.startOffset, this.endOffset);
|
|
1083
|
+
return c.data.slice(this.startOffset);
|
|
1084
|
+
}
|
|
1085
|
+
if (this.__sameContainerKids() && c && c.__children) {
|
|
1086
|
+
let s = '';
|
|
1087
|
+
const kids = c.__children();
|
|
1088
|
+
for (let i = this.startOffset; i < this.endOffset && i < kids.length; i++) s += kids[i].textContent;
|
|
1089
|
+
return s;
|
|
1090
|
+
}
|
|
1091
|
+
return '';
|
|
1092
|
+
}
|
|
915
1093
|
getBoundingClientRect() { return zeroRect(); }
|
|
916
1094
|
getClientRects() { return []; }
|
|
917
1095
|
detach() {}
|
|
918
1096
|
}
|
|
919
1097
|
|
|
920
|
-
function makeSelection() {
|
|
1098
|
+
function makeSelection(doc) {
|
|
921
1099
|
let ranges = [];
|
|
922
|
-
|
|
1100
|
+
const sel = {
|
|
923
1101
|
get rangeCount() { return ranges.length; },
|
|
924
|
-
get isCollapsed() { return ranges.every((r) => r.collapsed); },
|
|
1102
|
+
get isCollapsed() { return ranges.length === 0 || ranges.every((r) => r.collapsed); },
|
|
925
1103
|
get anchorNode() { return ranges[0] ? ranges[0].startContainer : null; },
|
|
926
1104
|
get focusNode() { return ranges[0] ? ranges[0].endContainer : null; },
|
|
927
1105
|
get anchorOffset() { return ranges[0] ? ranges[0].startOffset : 0; },
|
|
928
1106
|
get focusOffset() { return ranges[0] ? ranges[0].endOffset : 0; },
|
|
929
|
-
get type() { return ranges.length ? '
|
|
1107
|
+
get type() { return ranges.length === 0 ? 'None' : ranges[0].collapsed ? 'Caret' : 'Range'; },
|
|
930
1108
|
addRange(r) { ranges.push(r); },
|
|
931
1109
|
removeAllRanges() { ranges = []; },
|
|
932
1110
|
removeRange(r) { ranges = ranges.filter((x) => x !== r); },
|
|
933
1111
|
getRangeAt(i) { return ranges[i]; },
|
|
934
|
-
collapse(
|
|
935
|
-
|
|
1112
|
+
// collapse(node, offset): a single collapsed range at the point.
|
|
1113
|
+
collapse(node, offset = 0) { if (node == null) { ranges = []; return; } const r = new Range(doc); r.setStart(node, offset); r.setEnd(node, offset); ranges = [r]; },
|
|
1114
|
+
collapseToStart() { if (ranges[0]) ranges[0].collapse(true); },
|
|
1115
|
+
collapseToEnd() { if (ranges[0]) ranges[0].collapse(false); },
|
|
1116
|
+
// extend(node, offset): move the focus (range end) to the new point.
|
|
1117
|
+
extend(node, offset = 0) { if (!ranges[0]) ranges = [new Range(doc)]; ranges[0].setEnd(node, offset); },
|
|
1118
|
+
selectAllChildren(node) { const r = new Range(doc); r.selectNodeContents(node); ranges = [r]; },
|
|
1119
|
+
setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset) { const r = new Range(doc); r.setStart(anchorNode, anchorOffset); r.setEnd(focusNode, focusOffset); ranges = [r]; },
|
|
1120
|
+
empty() { ranges = []; },
|
|
1121
|
+
toString() { return ranges[0] ? ranges[0].toString() : ''; },
|
|
936
1122
|
};
|
|
1123
|
+
return sel;
|
|
937
1124
|
}
|
|
938
1125
|
|
|
939
1126
|
// TreeWalker / NodeIterator over the DOM (doubles for both — common subset).
|
|
@@ -1299,8 +1486,7 @@ export class Document extends Node {
|
|
|
1299
1486
|
}
|
|
1300
1487
|
createRange() { return new Range(this); }
|
|
1301
1488
|
createAttribute(name) { return { name, value: '', ownerElement: null }; }
|
|
1302
|
-
|
|
1303
|
-
getSelection() { if (!this.__selection) this.__selection = makeSelection(); return this.__selection; }
|
|
1489
|
+
getSelection() { if (!this.__selection) this.__selection = makeSelection(this); return this.__selection; }
|
|
1304
1490
|
importNode(node, deep) { return node.cloneNode(deep); }
|
|
1305
1491
|
adoptNode(node) { if (node.parentNode) node.parentNode.removeChild(node); adoptInto(node, this); return node; }
|
|
1306
1492
|
|
package/src/runtime/events.mjs
CHANGED
|
@@ -112,6 +112,33 @@ function normalizeOptions(options) {
|
|
|
112
112
|
return { capture: !!options.capture, once: !!options.once, passive: !!options.passive };
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// ---- shadow DOM event support ----
|
|
116
|
+
// Duck-typed (ShadowRoot sets `__isShadowRoot`/`host`) to avoid a dom.mjs import
|
|
117
|
+
// cycle. NONE of this runs unless the document has a shadow root attached — the
|
|
118
|
+
// hot dispatch path branches on `doc.__hasShadow` and skips it entirely.
|
|
119
|
+
function treeRootOf(node) { let n = node; while (n.parentNode) n = n.parentNode; return n; }
|
|
120
|
+
// Is `b` a shadow-including inclusive descendant of tree-root `root`? Walks up
|
|
121
|
+
// through parentNode, hopping shadow-root→host at each boundary.
|
|
122
|
+
function shadowInclusiveDescendant(b, root) {
|
|
123
|
+
let n = b;
|
|
124
|
+
while (n) {
|
|
125
|
+
if (n === root) return true;
|
|
126
|
+
n = n.parentNode || (n.__isShadowRoot ? n.host : null); // hop boundary, else stop
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
// WHATWG retarget(A, B): the version of A visible to a listener whose
|
|
131
|
+
// currentTarget is B. If A lives in a shadow tree that B is outside of, A is
|
|
132
|
+
// reported as that tree's host (and recursively, for nested shadows). The loop
|
|
133
|
+
// always terminates — each hop climbs one shadow boundary toward the document.
|
|
134
|
+
function retarget(a, b) {
|
|
135
|
+
for (;;) {
|
|
136
|
+
const r = treeRootOf(a);
|
|
137
|
+
if (!r.__isShadowRoot || shadowInclusiveDescendant(b, r)) return a;
|
|
138
|
+
a = r.host;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
115
142
|
export class EventTarget {
|
|
116
143
|
constructor() {
|
|
117
144
|
// type -> array of { callback, capture, once, passive }. Lazily created on
|
|
@@ -153,7 +180,8 @@ export class EventTarget {
|
|
|
153
180
|
|
|
154
181
|
dispatchEvent(event) {
|
|
155
182
|
if (!(event instanceof Event)) throw new TypeError('dispatchEvent requires an Event');
|
|
156
|
-
|
|
183
|
+
const target = this;
|
|
184
|
+
event.target = target;
|
|
157
185
|
|
|
158
186
|
// Single ancestor walk: build the path AND note whether any node on it has a
|
|
159
187
|
// listener for this type. React fires thousands of events with zero matching
|
|
@@ -161,11 +189,32 @@ export class EventTarget {
|
|
|
161
189
|
const type = event.type;
|
|
162
190
|
const path = [];
|
|
163
191
|
let hasListener = false;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
192
|
+
// Pay-for-what-you-use: the shadow-aware walk + per-invoke retargeting only
|
|
193
|
+
// runs when a shadow root exists in this document. Otherwise the original
|
|
194
|
+
// flat walk runs byte-for-byte (one boolean read, predicted false).
|
|
195
|
+
const doc = this.ownerDocument || this;
|
|
196
|
+
const useShadow = !!(doc && doc.__hasShadow);
|
|
197
|
+
if (!useShadow) {
|
|
198
|
+
let node = this;
|
|
199
|
+
while (node) {
|
|
200
|
+
path.push(node);
|
|
201
|
+
if (!hasListener) { const l = node.__listeners && node.__listeners.get(type); if (l && l.length) hasListener = true; }
|
|
202
|
+
node = node.parentNode || node.__owner || null;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
const targetRoot = treeRootOf(target);
|
|
206
|
+
let node = this;
|
|
207
|
+
while (node) {
|
|
208
|
+
path.push(node);
|
|
209
|
+
if (!hasListener) { const l = node.__listeners && node.__listeners.get(type); if (l && l.length) hasListener = true; }
|
|
210
|
+
if (node.__isShadowRoot) {
|
|
211
|
+
// a non-composed event stops at the shadow boundary enclosing its
|
|
212
|
+
// target; a composed event continues up through the host.
|
|
213
|
+
node = (!event.composed && node === targetRoot) ? null : (node.host || null);
|
|
214
|
+
} else {
|
|
215
|
+
node = node.parentNode || node.__owner || null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
169
218
|
}
|
|
170
219
|
event._path = path;
|
|
171
220
|
|
|
@@ -176,11 +225,19 @@ export class EventTarget {
|
|
|
176
225
|
activation = this.__preClickActivation();
|
|
177
226
|
}
|
|
178
227
|
|
|
228
|
+
// relatedTarget (focus/mouseenter/mouseleave) is retargeted per listener
|
|
229
|
+
// exactly like target. Only relevant under shadow with a non-null value.
|
|
230
|
+
const relatedTarget = useShadow ? event.relatedTarget : null;
|
|
179
231
|
const invoke = (node, phase) => {
|
|
180
232
|
const list = node.__listeners && node.__listeners.get(event.type);
|
|
181
233
|
if (!list || list.length === 0) return;
|
|
182
234
|
event.currentTarget = node;
|
|
183
235
|
event.eventPhase = phase;
|
|
236
|
+
// present `target`/`relatedTarget` retargeted to the listener's tree
|
|
237
|
+
if (useShadow) {
|
|
238
|
+
event.target = retarget(target, node);
|
|
239
|
+
if (relatedTarget != null) event.relatedTarget = retarget(relatedTarget, node);
|
|
240
|
+
}
|
|
184
241
|
// snapshot — listeners added during dispatch don't fire this round
|
|
185
242
|
for (const l of list.slice()) {
|
|
186
243
|
if (phase === PHASE_CAPTURING && !l.capture) continue;
|
|
@@ -221,6 +278,10 @@ export class EventTarget {
|
|
|
221
278
|
|
|
222
279
|
event.eventPhase = PHASE_NONE;
|
|
223
280
|
event.currentTarget = null;
|
|
281
|
+
if (useShadow) { // restore from any retargeting
|
|
282
|
+
event.target = target;
|
|
283
|
+
if (relatedTarget != null) event.relatedTarget = relatedTarget;
|
|
284
|
+
}
|
|
224
285
|
|
|
225
286
|
// canceled activation: undo the pre-click toggle if default was prevented.
|
|
226
287
|
// Otherwise a checkbox/radio toggle fires input then change (activation
|
package/src/runtime/index.mjs
CHANGED
|
@@ -38,13 +38,34 @@ function parseBufferCached(html) {
|
|
|
38
38
|
return soa;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Declarative Shadow DOM: promote every `<template shadowrootmode="open|closed">`
|
|
42
|
+
// into a real ShadowRoot on its parent (content moved in, template removed). This
|
|
43
|
+
// is a DOM walk, so it's gated by a cheap substring check on the HTML source in
|
|
44
|
+
// createEnvironment/reset — a document with no declarative shadow root pays
|
|
45
|
+
// nothing (the walk never runs).
|
|
46
|
+
function promoteDeclarativeShadowRoots(document) {
|
|
47
|
+
for (const tpl of Array.from(document.getElementsByTagName('template'))) {
|
|
48
|
+
const mode = tpl.getAttribute('shadowrootmode');
|
|
49
|
+
if (mode !== 'open' && mode !== 'closed') continue;
|
|
50
|
+
const host = tpl.parentNode;
|
|
51
|
+
if (!host || host.nodeType !== 1 || host.__shadow) continue;
|
|
52
|
+
const root = host.attachShadow({ mode, delegatesFocus: tpl.hasAttribute('shadowrootdelegatesfocus') });
|
|
53
|
+
if (tpl.content) for (const c of tpl.content.__children().slice()) root.appendChild(c);
|
|
54
|
+
host.removeChild(tpl);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
export function createEnvironment(html = '<!doctype html><html><head></head><body></body></html>', options = {}) {
|
|
42
59
|
// Layer 1: native parse → immutable SoA buffer (typed arrays, one boundary copy).
|
|
43
|
-
let
|
|
60
|
+
let currentHtml = String(html);
|
|
61
|
+
let soa = parseBufferCached(currentHtml);
|
|
44
62
|
|
|
45
63
|
// Layer 2: Document over the buffer (nodes inflate lazily from the arrays).
|
|
46
64
|
const document = new Document();
|
|
65
|
+
// declarative shadow roots only when the source even mentions them (cheap gate)
|
|
66
|
+
const maybePromote = () => { if (currentHtml.includes('shadowroot')) promoteDeclarativeShadowRoots(document); };
|
|
47
67
|
document.__load(soa);
|
|
68
|
+
maybePromote();
|
|
48
69
|
|
|
49
70
|
// Layer 3: lazy window.
|
|
50
71
|
const win = createWindow(document, options);
|
|
@@ -58,8 +79,9 @@ export function createEnvironment(html = '<!doctype html><html><head></head><bod
|
|
|
58
79
|
// Layer 5: arena-style reset. Re-point at the (re)parsed buffer, drop the
|
|
59
80
|
// owned overlay + node cache + materialized globals. Class machinery stays warm.
|
|
60
81
|
reset(nextHtml) {
|
|
61
|
-
if (nextHtml !== undefined)
|
|
82
|
+
if (nextHtml !== undefined) { currentHtml = String(nextHtml); soa = parseBufferCached(currentHtml); }
|
|
62
83
|
document.__load(soa); // drops __cache + __kids overlay, keeps the buffer if reused
|
|
84
|
+
maybePromote();
|
|
63
85
|
win.resetGlobals();
|
|
64
86
|
document.__active = null;
|
|
65
87
|
document.__cookieJar = null;
|
|
@@ -109,11 +109,6 @@ function splitTopLevel(s, sep) {
|
|
|
109
109
|
return out;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function elementChildren(node) {
|
|
113
|
-
const kids = typeof node.__children === 'function' ? node.__children() : Array.from(node.childNodes || []);
|
|
114
|
-
return kids.filter((n) => n.nodeType === 1);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
112
|
function matchAttr(el, a) {
|
|
118
113
|
const raw = el.getAttribute(a.name); // single lookup (null = absent)
|
|
119
114
|
if (raw === null) return false;
|
package/src/runtime/window.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { makeGetComputedStyle } from './cascade.mjs';
|
|
14
14
|
import {
|
|
15
15
|
Node, Element, Text, Comment, Document, DocumentFragment, DocumentType, Event, CustomEvent,
|
|
16
|
-
MutationObserver, DOMParser, XMLSerializer,
|
|
16
|
+
MutationObserver, DOMParser, XMLSerializer, ShadowRoot,
|
|
17
17
|
} from './dom.mjs';
|
|
18
18
|
import {
|
|
19
19
|
EventTarget,
|
|
@@ -244,6 +244,7 @@ function makeXHR() {
|
|
|
244
244
|
const STATIC_BASE = {
|
|
245
245
|
// DOM + event constructors (cheap class refs)
|
|
246
246
|
Node, Element, Text, Comment, Document, DocumentFragment, DocumentType, EventTarget,
|
|
247
|
+
ShadowRoot,
|
|
247
248
|
Event, CustomEvent,
|
|
248
249
|
UIEvent, MouseEvent, PointerEvent, KeyboardEvent, InputEvent, FocusEvent,
|
|
249
250
|
CompositionEvent, WheelEvent, TouchEvent, DragEvent, ProgressEvent, ClipboardEvent,
|
|
@@ -302,7 +303,7 @@ const STATIC_BASE = {
|
|
|
302
303
|
HTMLHeadElement: tagClass('head'), HTMLHtmlElement: tagClass('html'),
|
|
303
304
|
HTMLDataElement: tagClass('data'), HTMLTimeElement: tagClass('time'),
|
|
304
305
|
HTMLSlotElement: tagClass('slot'), HTMLMenuElement: tagClass('menu'),
|
|
305
|
-
HTMLDocument: Document,
|
|
306
|
+
HTMLDocument: Document,
|
|
306
307
|
MutationObserver, DOMParser, XMLSerializer,
|
|
307
308
|
URL: TURBO_URL, URLSearchParams,
|
|
308
309
|
Blob: globalThis.Blob, File: TURBO_FILE, FileReader,
|
|
Binary file
|