@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 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** — ~120× jsdom / ~40× happy-dom on a realistic suite (parse-memoized repeated shells), 1837× faster HTML parsing, and it beats happy-dom on repeated queries while staying 99.7% spec-correct.
18
- - 🎯 **Honest, not lying** — no fake layout numbers; `getBoundingClientRect()` is zeros and
19
- `getComputedStyle` reflects only what you set. Geometry tests belong in a real browser.
17
+ - ⚡ **Faster than both** — ~130× jsdom / ~45× happy-dom on a realistic suite (parse-memoized repeated shells), ~835× 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 / `getComputedStyle` cascade | ❌ (honest stub) | partial | partial |
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) | **0.022** | 1.12 | 3.47 |
109
- | **per-file setup** (ops/s) | **~500k** | 396 | 144 |
110
- | **parse 56 KB SSR** (ops/s) | **478** | 43 | 26 |
111
- | **parse 20 KB real page** (ops/s) | **2,800** | 600 | 290 |
112
- | repeated query throughput (iters/s) | **994k** | 692k | 3k |
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
- **~40× happy-dom and ~120× jsdom**, edges happy-dom on repeated queries, and parses
117
- **1837×** faster, all at 99.7% conformance.
120
+ **~45× happy-dom and ~130× jsdom**, runs repeated queries **~2.2× happy-dom**
121
+ (~460× jsdom), and parses real pages/SSR documents **~835×** 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 (~40× happy-dom, ~120× jsdom on a repeated-shell suite), parsing,
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 inline-only** — it reflects the `style` attribute and explicitly
155
- set properties, never an invented cascade. Style/geometry assertions belong in a real
156
- browser (Playwright/WebDriver).
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.31",
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",
@@ -106,35 +106,77 @@ function parseStylesheet(css, startOrder, rules) {
106
106
  return order;
107
107
  }
108
108
 
109
- function buildIndex(document) {
110
- const styles = document.getElementsByTagName('style');
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
- function getIndex(doc, v) {
118
- const cached = doc.__styleIndex;
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(doc);
121
- doc.__styleIndex = { v, rules };
124
+ const rules = buildIndex(scope);
125
+ scope.__styleIndex = { v, rules };
122
126
  return rules;
123
127
  }
124
128
 
125
- function resolve(el, rules) {
126
- const out = new Map();
127
- const matched = [];
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
- try { if (matchesSelector(el, r.selector)) matched.push(r); } catch { /* unsupported selector → skip */ }
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
- return (el) => {
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
- const map = doc ? resolve(el, getIndex(doc, v)) : new Map();
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
- const target = function () {};
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') {
@@ -181,11 +181,24 @@ export class Node extends EventTarget {
181
181
 
182
182
  get isConnected() {
183
183
  let n = this;
184
- while (n.parentNode) n = n.parentNode;
185
- return n.nodeType === DOCUMENT_NODE;
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
- if (t === 'input' || t === 'textarea') return this.__value !== undefined ? this.__value : (this.getAttribute('value') ?? '');
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 (open by default; a detached fragment with a host back-reference)
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
- const root = new DocumentFragment(this.ownerDocument);
792
- root.host = this;
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
- // Range functional enough for selection bookkeeping (user-event), zero geometry.
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, 0); }
902
- setStartAfter(node) { this.setStart(node.parentNode, 0); }
903
- setEndBefore(node) { this.setEnd(node.parentNode, 0); }
904
- setEndAfter(node) { this.setEnd(node.parentNode, 0); }
905
- selectNode(node) { this.startContainer = this.endContainer = node; this.__sync(); }
906
- selectNodeContents(node) { this.startContainer = this.endContainer = node; this.startOffset = 0; this.endOffset = node.childNodes ? node.childNodes.length : 0; this.__sync(); }
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
- get commonAncestorContainer() { return this.startContainer; }
910
- cloneRange() { const r = new Range(this.__doc); Object.assign(r, this); return r; }
911
- cloneContents() { return this.__doc.createDocumentFragment(); }
912
- deleteContents() {}
913
- insertNode(node) { if (this.startContainer && this.startContainer.insertBefore) this.startContainer.insertBefore(node, this.startContainer.childNodes[this.startOffset] ?? null); }
914
- surroundContents(node) { this.insertNode(node); }
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
- return {
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 ? 'Range' : 'None'; },
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() {}, extend() {}, selectAllChildren() {}, setBaseAndExtent() {}, empty() { ranges = []; },
935
- toString() { return ''; },
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
- createComment(data) { return new Comment(this, String(data)); }
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
 
@@ -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
- event.target = this;
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
- let node = this;
165
- while (node) {
166
- path.push(node);
167
- if (!hasListener) { const l = node.__listeners && node.__listeners.get(type); if (l && l.length) hasListener = true; }
168
- node = node.parentNode || node.__owner || null;
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
@@ -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 soa = parseBufferCached(String(html));
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) soa = parseBufferCached(String(nextHtml));
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;
@@ -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, ShadowRoot: DocumentFragment,
306
+ HTMLDocument: Document,
306
307
  MutationObserver, DOMParser, XMLSerializer,
307
308
  URL: TURBO_URL, URLSearchParams,
308
309
  Blob: globalThis.Blob, File: TURBO_FILE, FileReader,