@atolljs/islands 0.1.0
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 +346 -0
- package/package.json +29 -0
- package/src/app.ts +81 -0
- package/src/callbackProps.ts +75 -0
- package/src/index.ts +40 -0
- package/src/island.ts +905 -0
- package/src/memory.ts +30 -0
- package/src/ops.ts +215 -0
- package/src/react-reconciler.d.ts +18 -0
- package/src/worker/defineWorkers.ts +666 -0
- package/src/worker/dom/css.ts +152 -0
- package/src/worker/dom/document.ts +589 -0
- package/src/worker/dom/element.ts +656 -0
- package/src/worker/dom/html.ts +107 -0
- package/src/worker/dom/node.ts +481 -0
- package/src/worker/dom/selectors.ts +108 -0
- package/src/worker/dom/window.ts +528 -0
- package/src/worker/hostConfig.ts +446 -0
- package/src/worker/index.ts +78 -0
- package/src/worker/instance.ts +317 -0
- package/src/worker/proxyDom.ts +28 -0
- package/src/worker/reactInstance.ts +74 -0
- package/src/worker/slot.tsx +18 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { parseDocument, ElementType } from 'htmlparser2';
|
|
2
|
+
import {
|
|
3
|
+
allocId,
|
|
4
|
+
bumpOpsVersion,
|
|
5
|
+
getActiveInstance,
|
|
6
|
+
getLastActiveInstance,
|
|
7
|
+
getInstanceSize,
|
|
8
|
+
instances,
|
|
9
|
+
markInstanceActive,
|
|
10
|
+
pushOp,
|
|
11
|
+
registerHandler,
|
|
12
|
+
unregisterHandler,
|
|
13
|
+
} from '../instance';
|
|
14
|
+
import type { ElementInstance, HostInstance, TextInstance } from '../instance';
|
|
15
|
+
import type { EventPayload, Op } from '../../ops';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
ProxyNode,
|
|
19
|
+
ProxyText,
|
|
20
|
+
ProxyComment,
|
|
21
|
+
ProxyFragment,
|
|
22
|
+
allocPhantomId,
|
|
23
|
+
rejectForeignChild,
|
|
24
|
+
listenerOptsForWire,
|
|
25
|
+
normalizeListenerOptions,
|
|
26
|
+
type ProxyEventHandler,
|
|
27
|
+
} from './node';
|
|
28
|
+
import { ProxyElement } from './element';
|
|
29
|
+
import type { ProxyClassList } from './css';
|
|
30
|
+
import { parseChildren, serializeNode } from './html';
|
|
31
|
+
import { parseSelector, matchCompound, matchesChain } from './selectors';
|
|
32
|
+
import type { WindowShim, WindowFacadeBundle } from './window';
|
|
33
|
+
|
|
34
|
+
/* ── The document ──────────────────────────────────────────────────────── */
|
|
35
|
+
|
|
36
|
+
export interface ProxyDocument {
|
|
37
|
+
/**
|
|
38
|
+
* The island's root container — driver-side id 0. `body` and
|
|
39
|
+
* `documentElement` are aliases of it; appending to either lands elements
|
|
40
|
+
* at the island root. Its own attr/style/listen mutations target the real
|
|
41
|
+
* container element.
|
|
42
|
+
*/
|
|
43
|
+
readonly body: ProxyElement;
|
|
44
|
+
readonly documentElement: ProxyElement;
|
|
45
|
+
createElement(tag: string): ProxyElement;
|
|
46
|
+
/**
|
|
47
|
+
* Namespaced creation for SVG/MathML trees — the `create` op carries the
|
|
48
|
+
* namespace so the driver uses `createElementNS`. Needed by libraries that
|
|
49
|
+
* build SVG imperatively (d3-style code).
|
|
50
|
+
*/
|
|
51
|
+
createElementNS(ns: string, tag: string): ProxyElement;
|
|
52
|
+
createTextNode(text: string): ProxyText;
|
|
53
|
+
/**
|
|
54
|
+
* A comment node — nodeType 8 backed by a REAL empty text node
|
|
55
|
+
* driver-side (the wire has no comment op) so it anchors `before:`
|
|
56
|
+
* positions like a real comment. `data` writes stay shadow-only.
|
|
57
|
+
*/
|
|
58
|
+
createComment(data?: string): ProxyComment;
|
|
59
|
+
/**
|
|
60
|
+
* A phantom container for batching — appending the fragment splices its
|
|
61
|
+
* children into the target like the DOM does (each child emits its own
|
|
62
|
+
* append op). Fragments never exist driver-side.
|
|
63
|
+
*/
|
|
64
|
+
createDocumentFragment(): ProxyFragment;
|
|
65
|
+
/** DOM `importNode` semantics — a deep/shallow clone through this
|
|
66
|
+
* document (proxy nodes are single-instance, so cloning IS importing). */
|
|
67
|
+
importNode(node: ProxyNode, deep?: boolean): ProxyNode;
|
|
68
|
+
/** 'about:blank' — no real document URL exists worker-side. */
|
|
69
|
+
readonly baseURI: string;
|
|
70
|
+
/**
|
|
71
|
+
* Document-level listeners: emitted as `listen` ops on id 0 — the driver
|
|
72
|
+
* attaches them to the island's real container element, so delegated
|
|
73
|
+
* handlers see every event that bubbles inside the island. This is what
|
|
74
|
+
* library-style `document.addEventListener('click', delegate)` needs.
|
|
75
|
+
* `options` carries `{once, passive, capture}` onto the wire.
|
|
76
|
+
*/
|
|
77
|
+
addEventListener(
|
|
78
|
+
type: string,
|
|
79
|
+
fn: ProxyEventHandler,
|
|
80
|
+
options?: boolean | AddEventListenerOptions,
|
|
81
|
+
): void;
|
|
82
|
+
removeEventListener(
|
|
83
|
+
type: string,
|
|
84
|
+
fn: ProxyEventHandler,
|
|
85
|
+
options?: boolean | EventListenerOptions,
|
|
86
|
+
): void;
|
|
87
|
+
/** id-map lookup over the CONNECTED shadow tree (disconnected nodes with
|
|
88
|
+
* an id set are remembered but not returned, matching real-DOM scoping). */
|
|
89
|
+
getElementById(id: string): ProxyElement | null;
|
|
90
|
+
/** Simple matcher: tag/.class/#id/[attr]/[attr="v"] + descendant
|
|
91
|
+
* combinators. Anything else throws a clear unsupported-selector error. */
|
|
92
|
+
querySelector(selector: string): ProxyElement | null;
|
|
93
|
+
querySelectorAll(selector: string): ProxyElement[];
|
|
94
|
+
/** Wrap an existing host-instance record (e.g. a React-rendered element
|
|
95
|
+
* reached via the shared `instances` map) so imperative code can navigate
|
|
96
|
+
* or mutate around it — the bridge for nesting proxy trees inside
|
|
97
|
+
* React-rendered parents and vice versa. */
|
|
98
|
+
adopt(instance: HostInstance): ProxyNode;
|
|
99
|
+
/**
|
|
100
|
+
* Mark `el` as reporting the pushed container size — the driver's only
|
|
101
|
+
* measured box is the island container, so geometry reads
|
|
102
|
+
* (`clientWidth/Height`, `offsetWidth/Height`, `getBoundingClientRect`)
|
|
103
|
+
* on marked elements return the last `setSize` push exactly like
|
|
104
|
+
* `doc.body`/`documentElement`. Only honest for elements that actually
|
|
105
|
+
* fill the island's box (a map viewport, a canvas host).
|
|
106
|
+
*/
|
|
107
|
+
markContainer(el: ProxyElement): void;
|
|
108
|
+
/**
|
|
109
|
+
* Register a handler fired every time the driver pushes a new container
|
|
110
|
+
* size — the app's resize signal. Handlers run inside the instance's task
|
|
111
|
+
* scope, so their proxy-DOM mutations emit ops and `emit` routes.
|
|
112
|
+
*/
|
|
113
|
+
onResize(cb: (w: number, h: number) => void): void;
|
|
114
|
+
/**
|
|
115
|
+
* The window facade `installDomShim` installed this document under —
|
|
116
|
+
* what `document.defaultView` reads as (`window.getComputedStyle` etc).
|
|
117
|
+
* undefined until the shim installs.
|
|
118
|
+
*/
|
|
119
|
+
readonly defaultView?: WindowShim;
|
|
120
|
+
/** No measurement channel — returns an all-empty declaration (warns once). */
|
|
121
|
+
getComputedStyle(el: ProxyElement): CSSStyleDeclaration;
|
|
122
|
+
/** Unregister every handler this document's listeners hold. Called by the
|
|
123
|
+
* worker on rebuild so stale handler ids can't be dispatched into dead
|
|
124
|
+
* nodes; mutating a disposed document throws. */
|
|
125
|
+
dispose(): void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class InternalDocument implements ProxyDocument {
|
|
129
|
+
readonly instance: string;
|
|
130
|
+
readonly _root: ProxyElement;
|
|
131
|
+
readonly _wrappers = new Map<number, ProxyNode>();
|
|
132
|
+
readonly _ids = new Map<string, ProxyElement>();
|
|
133
|
+
/** handler ids this document registered — disposed on rebuild. */
|
|
134
|
+
readonly _handlerIds = new Set<number>();
|
|
135
|
+
/**
|
|
136
|
+
* instance ids geometry reads answer for — id 0 (the root) plus every
|
|
137
|
+
* markContainer()ed element. The pushed box is the ISLAND CONTAINER's,
|
|
138
|
+
* so a marked element claims "my box is the container's box".
|
|
139
|
+
*/
|
|
140
|
+
readonly _containerIds = new Set<number>([0]);
|
|
141
|
+
/** Handlers fired when a setSize task pushes a new container size. */
|
|
142
|
+
private readonly _resizeHandlers = new Set<(w: number, h: number) => void>();
|
|
143
|
+
/** The window facade bundle — built lazily by windowFacadeFor, shared by
|
|
144
|
+
* installDomShim and the instance dispatcher. */
|
|
145
|
+
_windowBundle?: WindowFacadeBundle;
|
|
146
|
+
/** what `document.defaultView` reads as (`window.getComputedStyle` etc). */
|
|
147
|
+
get defaultView(): WindowShim | undefined {
|
|
148
|
+
return this._windowBundle?.facade;
|
|
149
|
+
}
|
|
150
|
+
/** Document-level listeners — separate table from the root element's, so
|
|
151
|
+
* doc.addEventListener and body.addEventListener don't wrongly dedupe
|
|
152
|
+
* against each other. Both emit `listen` on id 0. */
|
|
153
|
+
private readonly _docListeners: Array<{
|
|
154
|
+
type: string;
|
|
155
|
+
fn: ProxyEventHandler;
|
|
156
|
+
hid: number;
|
|
157
|
+
capture: boolean;
|
|
158
|
+
}> = [];
|
|
159
|
+
private readonly _warned = new Set<string>();
|
|
160
|
+
_disposed = false;
|
|
161
|
+
/** Set by installDomShim — restore globals when this document dies. */
|
|
162
|
+
_uninstallShim: (() => void) | null = null;
|
|
163
|
+
|
|
164
|
+
constructor(instance: string) {
|
|
165
|
+
this.instance = instance;
|
|
166
|
+
// id 0 is the driver-side island container sentinel — deliberately NOT
|
|
167
|
+
// registered in `instances` (the driver's nodes map already maps 0 → el).
|
|
168
|
+
this._root = new ProxyElement(this, {
|
|
169
|
+
kind: 'element',
|
|
170
|
+
id: 0,
|
|
171
|
+
type: '#root',
|
|
172
|
+
instance,
|
|
173
|
+
props: {},
|
|
174
|
+
listenerSlots: {},
|
|
175
|
+
});
|
|
176
|
+
this._wrappers.set(0, this._root);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
get body(): ProxyElement {
|
|
180
|
+
return this._root;
|
|
181
|
+
}
|
|
182
|
+
get documentElement(): ProxyElement {
|
|
183
|
+
return this._root;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** No hit-testing exists worker-side — honest null (a11y-layer probing). */
|
|
187
|
+
elementFromPoint(_x: number, _y: number): null {
|
|
188
|
+
this._assertAlive();
|
|
189
|
+
this._warn('elementFromPoint');
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_assertAlive(): void {
|
|
194
|
+
if (this._disposed) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
'proxyDom: this document was disposed by an updateProps/remount rebuild — its nodes are detached',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
_warn(feature: string): void {
|
|
202
|
+
if (this._warned.has(feature)) return;
|
|
203
|
+
this._warned.add(feature);
|
|
204
|
+
console.warn(
|
|
205
|
+
`[proxyDom] '${feature}' has no measurement channel — the only geometry the worker sees is the pushed container size (setSize); reads on unmarked elements return 0/empty.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
markContainer(el: ProxyElement): void {
|
|
210
|
+
this._assertAlive();
|
|
211
|
+
this._containerIds.add(el.instance.id);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The size a geometry read on `el` may report: the pushed container box
|
|
216
|
+
* when `el` is the root or was markContainer()ed, undefined otherwise
|
|
217
|
+
* (also undefined when nothing has been pushed yet — reads return 0).
|
|
218
|
+
*/
|
|
219
|
+
_sizeFor(el: ProxyElement): { w: number; h: number } | undefined {
|
|
220
|
+
if (!this._containerIds.has(el.instance.id)) return undefined;
|
|
221
|
+
return getInstanceSize(this.instance);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
onResize(cb: (w: number, h: number) => void): void {
|
|
225
|
+
this._assertAlive();
|
|
226
|
+
this._resizeHandlers.add(cb);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Fire the doc's resize handlers — the worker's `setSize` task calls this
|
|
231
|
+
* inside the instance's scope, so handler mutations emit ops on the instance
|
|
232
|
+
* queue and come back in the task's return batch.
|
|
233
|
+
*/
|
|
234
|
+
_notifySize(w: number, h: number): void {
|
|
235
|
+
for (const cb of [...this._resizeHandlers]) cb(w, h);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Keep the getElementById map pointing at each element's CURRENT id. */
|
|
239
|
+
_trackId(el: ProxyElement): void {
|
|
240
|
+
for (const [k, v] of this._ids) if (v === el) this._ids.delete(k);
|
|
241
|
+
const id = el._attrs.get('id');
|
|
242
|
+
if (id !== undefined && id !== '') this._ids.set(id, el);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The synthetic text child an element's `textContent` setter leaves in
|
|
246
|
+
* the shadow tree — no driver-side node, so its writes retarget parent. */
|
|
247
|
+
_phantomText(parent: ProxyNode, text: string): ProxyText {
|
|
248
|
+
const t = new ProxyText(this, {
|
|
249
|
+
kind: 'text',
|
|
250
|
+
id: allocPhantomId(),
|
|
251
|
+
text,
|
|
252
|
+
instance: parent.instance.instance,
|
|
253
|
+
});
|
|
254
|
+
t._parent = parent;
|
|
255
|
+
return t;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Enrich a dispatched payload before handing it to a proxy listener:
|
|
260
|
+
* - `target` = the proxy node for targetId, looked up in the shared
|
|
261
|
+
* instance space and wrapped in THIS document's wrapper map (so
|
|
262
|
+
* `e.target.closest`/`.dataset`/`.contains` behave like the real DOM
|
|
263
|
+
* for proxy-created nodes).
|
|
264
|
+
* - `currentTarget` = `self` — the node/document/facade the listener was
|
|
265
|
+
* attached to (the handler is also invoked with `this` = self). For
|
|
266
|
+
* document/window listeners this is the document/facade OBJECT, not
|
|
267
|
+
* the id-0 root element dispatch() would otherwise stamp.
|
|
268
|
+
* - `composedPath()` = synthesized from the enriched target's parent
|
|
269
|
+
* chain, with `self` appended when the chain doesn't already reach it
|
|
270
|
+
* (delegated handlers iterate the path until they hit their own
|
|
271
|
+
* element — Svelte's handle_event_propagation terminates there).
|
|
272
|
+
* - `preventDefault`/`stopPropagation`/`stopImmediatePropagation` =
|
|
273
|
+
* synthesized NO-OPS. They cannot cancel anything — the real event
|
|
274
|
+
* already dispatched on the main thread before this payload crossed
|
|
275
|
+
* postMessage — but library code written against real DOM events calls
|
|
276
|
+
* them unconditionally, so the wrapper supplies them.
|
|
277
|
+
*/
|
|
278
|
+
_enrichEvent(payload: EventPayload, self?: unknown): EventPayload {
|
|
279
|
+
const out: EventPayload & {
|
|
280
|
+
currentTarget?: unknown;
|
|
281
|
+
composedPath?: () => unknown[];
|
|
282
|
+
} = {
|
|
283
|
+
...payload,
|
|
284
|
+
preventDefault: () => {},
|
|
285
|
+
stopPropagation: () => {},
|
|
286
|
+
stopImmediatePropagation: () => {},
|
|
287
|
+
};
|
|
288
|
+
if (self !== undefined) out.currentTarget = self;
|
|
289
|
+
const id = payload.targetId;
|
|
290
|
+
if (typeof id === 'number') {
|
|
291
|
+
const instance = instances.get(id);
|
|
292
|
+
if (instance !== undefined) {
|
|
293
|
+
const node = this._wrap(instance);
|
|
294
|
+
// Reflect the dispatched form state into the SHADOW attrs —
|
|
295
|
+
// `e.target.value`/`e.target.checked` are the DOM idioms input
|
|
296
|
+
// handlers read, but the proxy's own fields are whatever the last
|
|
297
|
+
// attr op set (stale). Writing the public setters would emit an
|
|
298
|
+
// attr op echoing the user's own keystroke back to the DOM, so the
|
|
299
|
+
// shadow map is updated directly.
|
|
300
|
+
if (node instanceof ProxyElement) {
|
|
301
|
+
if (payload.value !== undefined) node._attrs.set('value', String(payload.value));
|
|
302
|
+
if (payload.checked !== undefined) {
|
|
303
|
+
if (payload.checked) node._attrs.set('checked', '');
|
|
304
|
+
else node._attrs.delete('checked');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
out.target = node;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (out.composedPath === undefined) {
|
|
311
|
+
const path: unknown[] = [];
|
|
312
|
+
let n = out.target as ProxyNode | undefined;
|
|
313
|
+
while (n instanceof ProxyNode) {
|
|
314
|
+
path.push(n);
|
|
315
|
+
n = n._parent ?? undefined;
|
|
316
|
+
}
|
|
317
|
+
if (self !== undefined && !path.includes(self)) path.push(self);
|
|
318
|
+
out.composedPath = () => path;
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
createElement(tag: string): ProxyElement {
|
|
324
|
+
return this._newElement(tag.toLowerCase(), undefined);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
createElementNS(ns: string, tag: string): ProxyElement {
|
|
328
|
+
return this._newElement(tag, ns);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private _newElement(type: string, ns: string | undefined): ProxyElement {
|
|
332
|
+
this._assertAlive();
|
|
333
|
+
const instance: ElementInstance = {
|
|
334
|
+
kind: 'element',
|
|
335
|
+
id: allocId(),
|
|
336
|
+
type,
|
|
337
|
+
ns,
|
|
338
|
+
instance: this.instance,
|
|
339
|
+
props: {},
|
|
340
|
+
listenerSlots: {},
|
|
341
|
+
};
|
|
342
|
+
instances.set(instance.id, instance);
|
|
343
|
+
pushOp(this.instance, { t: 'create', id: instance.id, type: instance.type, props: {}, ns: instance.ns });
|
|
344
|
+
if (getActiveInstance() !== this.instance) bumpOpsVersion();
|
|
345
|
+
return this._wrap(instance) as ProxyElement;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
createTextNode(text: string): ProxyText {
|
|
349
|
+
this._assertAlive();
|
|
350
|
+
const instance: TextInstance = {
|
|
351
|
+
kind: 'text',
|
|
352
|
+
id: allocId(),
|
|
353
|
+
text: String(text),
|
|
354
|
+
instance: this.instance,
|
|
355
|
+
};
|
|
356
|
+
instances.set(instance.id, instance);
|
|
357
|
+
pushOp(this.instance, { t: 'text', id: instance.id, text: instance.text });
|
|
358
|
+
if (getActiveInstance() !== this.instance) bumpOpsVersion();
|
|
359
|
+
return this._wrap(instance) as ProxyText;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
adopt(instance: HostInstance): ProxyNode {
|
|
363
|
+
this._assertAlive();
|
|
364
|
+
return this._wrap(instance);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
createDocumentFragment(): ProxyFragment {
|
|
368
|
+
this._assertAlive();
|
|
369
|
+
return new ProxyFragment(this, {
|
|
370
|
+
kind: 'element',
|
|
371
|
+
id: allocPhantomId(),
|
|
372
|
+
type: '#fragment',
|
|
373
|
+
instance: this.instance,
|
|
374
|
+
props: {},
|
|
375
|
+
listenerSlots: {},
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* A comment — nodeType 8 over a real EMPTY text instance. The wire has no
|
|
381
|
+
* comment op: the `text` op with '' gives it a driver-side node that can
|
|
382
|
+
* anchor `before:` positions, while `data` stays shadow-only (emitting it
|
|
383
|
+
* would render comment text on the page).
|
|
384
|
+
*/
|
|
385
|
+
createComment(data = ''): ProxyComment {
|
|
386
|
+
const backing = this.createTextNode('');
|
|
387
|
+
const comment = new ProxyComment(this, backing.instance);
|
|
388
|
+
comment.instance.text = String(data);
|
|
389
|
+
// createTextNode cached a ProxyText wrapper for this id in _wrappers —
|
|
390
|
+
// replace it so later adopt()/_wrap() calls hand back the comment view.
|
|
391
|
+
this._wrappers.set(backing.instance.id, comment);
|
|
392
|
+
return comment;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* DOM `importNode` — a clone through this document. Foreign (non-proxy)
|
|
397
|
+
* nodes have no shadow shape to copy; they're rejected like any other
|
|
398
|
+
* foreign child.
|
|
399
|
+
*/
|
|
400
|
+
importNode(node: ProxyNode, deep = false): ProxyNode {
|
|
401
|
+
this._assertAlive();
|
|
402
|
+
if (!(node instanceof ProxyNode)) rejectForeignChild(node);
|
|
403
|
+
return node.cloneNode(deep);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* 'about:blank' — no real document URL exists worker-side. A plain field
|
|
408
|
+
* (not a getter) so adapters may assign it like a normal document prop.
|
|
409
|
+
*/
|
|
410
|
+
readonly baseURI: string = 'about:blank';
|
|
411
|
+
|
|
412
|
+
/** Wrap (or return the existing wrapper for) a shared instance record. */
|
|
413
|
+
_wrap(instance: HostInstance): ProxyNode {
|
|
414
|
+
let node = this._wrappers.get(instance.id);
|
|
415
|
+
if (node === undefined) {
|
|
416
|
+
node =
|
|
417
|
+
instance.kind === 'text'
|
|
418
|
+
? new ProxyText(this, instance)
|
|
419
|
+
: new ProxyElement(this, instance as ElementInstance);
|
|
420
|
+
this._wrappers.set(instance.id, node);
|
|
421
|
+
}
|
|
422
|
+
return node;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Document-level listener — same currency as element addEventListener but
|
|
427
|
+
* targeting id 0 (the island's container element). The driver's listen op
|
|
428
|
+
* attaches a real DOM listener on the container, so events bubbling up
|
|
429
|
+
* from any island child dispatch back here. Used by library code doing
|
|
430
|
+
* delegated `document.addEventListener(...)`.
|
|
431
|
+
*/
|
|
432
|
+
addEventListener(
|
|
433
|
+
type: string,
|
|
434
|
+
fn: ProxyEventHandler,
|
|
435
|
+
options?: boolean | AddEventListenerOptions,
|
|
436
|
+
): void {
|
|
437
|
+
this._assertAlive();
|
|
438
|
+
const opts = normalizeListenerOptions(options);
|
|
439
|
+
// Same (type, listener, capture) dedupe as the DOM.
|
|
440
|
+
if (
|
|
441
|
+
this._docListeners.some(
|
|
442
|
+
(l) => l.type === type && l.fn === fn && l.capture === opts.capture,
|
|
443
|
+
)
|
|
444
|
+
) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const hid = registerHandler(
|
|
448
|
+
(p) => {
|
|
449
|
+
try {
|
|
450
|
+
fn.call(this, this._enrichEvent(p as EventPayload, this));
|
|
451
|
+
} finally {
|
|
452
|
+
if (opts.once) this.removeEventListener(type, fn, { capture: opts.capture });
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
this.instance,
|
|
456
|
+
0, // document-level listener → target id 0 is the island root
|
|
457
|
+
);
|
|
458
|
+
this._docListeners.push({ type, fn, hid, capture: opts.capture });
|
|
459
|
+
this._handlerIds.add(hid);
|
|
460
|
+
pushOp(this.instance, { t: 'listen', id: 0, type, handler: hid, opts: listenerOptsForWire(options) });
|
|
461
|
+
if (getActiveInstance() !== this.instance) bumpOpsVersion();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
removeEventListener(
|
|
465
|
+
type: string,
|
|
466
|
+
fn: ProxyEventHandler,
|
|
467
|
+
options?: boolean | EventListenerOptions,
|
|
468
|
+
): void {
|
|
469
|
+
this._assertAlive();
|
|
470
|
+
const capture = normalizeListenerOptions(options).capture;
|
|
471
|
+
const index = this._docListeners.findIndex(
|
|
472
|
+
(l) => l.type === type && l.fn === fn && l.capture === capture,
|
|
473
|
+
);
|
|
474
|
+
if (index === -1) return;
|
|
475
|
+
const [entry] = this._docListeners.splice(index, 1);
|
|
476
|
+
unregisterHandler(entry.hid);
|
|
477
|
+
this._handlerIds.delete(entry.hid);
|
|
478
|
+
pushOp(this.instance, {
|
|
479
|
+
t: 'unlisten',
|
|
480
|
+
id: 0,
|
|
481
|
+
type,
|
|
482
|
+
handler: entry.hid,
|
|
483
|
+
opts: entry.capture ? { capture: true } : undefined,
|
|
484
|
+
});
|
|
485
|
+
if (getActiveInstance() !== this.instance) bumpOpsVersion();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
getElementById(id: string): ProxyElement | null {
|
|
489
|
+
const el = this._ids.get(id);
|
|
490
|
+
return el !== undefined && el.isConnected ? el : null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
querySelector(selector: string): ProxyElement | null {
|
|
494
|
+
return this.querySelectorAll(selector)[0] ?? null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
querySelectorAll(selector: string): ProxyElement[] {
|
|
498
|
+
let chain = parseSelector(selector);
|
|
499
|
+
// body/documentElement/html all alias the id-0 root. A leading bare
|
|
500
|
+
// `body`/`html` compound is satisfied BY the root — Vue's Teleport
|
|
501
|
+
// resolves `to="body"` through this path — so pin it there and match
|
|
502
|
+
// the rest of the chain against descendants.
|
|
503
|
+
const first = chain[0];
|
|
504
|
+
if (
|
|
505
|
+
(first.tag === 'body' || first.tag === 'html') &&
|
|
506
|
+
first.id === undefined &&
|
|
507
|
+
first.classes.length === 0 &&
|
|
508
|
+
first.attrs.length === 0
|
|
509
|
+
) {
|
|
510
|
+
chain = chain.slice(1);
|
|
511
|
+
}
|
|
512
|
+
if (chain.length === 0) return [this._root];
|
|
513
|
+
const out: ProxyElement[] = [];
|
|
514
|
+
const walk = (node: ProxyNode): void => {
|
|
515
|
+
for (const child of node._children) {
|
|
516
|
+
if (child instanceof ProxyElement && matchesChain(child, chain)) out.push(child);
|
|
517
|
+
walk(child);
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
walk(this._root);
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
getComputedStyle(_el: ProxyElement): CSSStyleDeclaration {
|
|
525
|
+
this._warn('getComputedStyle');
|
|
526
|
+
// Every property reads ''; getPropertyValue('x') returns '' too.
|
|
527
|
+
return new Proxy({} as Record<string, unknown>, {
|
|
528
|
+
get: (_t, prop) => (prop === 'getPropertyValue' ? () => '' : ''),
|
|
529
|
+
}) as unknown as CSSStyleDeclaration;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
dispose(): void {
|
|
533
|
+
// Tear down the shim FIRST if this doc was installed — the rebuild path
|
|
534
|
+
// (updateProps) swaps in a fresh doc and re-installs.
|
|
535
|
+
this._uninstallShim?.();
|
|
536
|
+
this._uninstallShim = null;
|
|
537
|
+
this._disposed = true;
|
|
538
|
+
if (instanceDocs.get(this.instance) === this) instanceDocs.delete(this.instance);
|
|
539
|
+
this._resizeHandlers.clear();
|
|
540
|
+
for (const hid of this._handlerIds) unregisterHandler(hid);
|
|
541
|
+
this._handlerIds.clear();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* A fresh proxy document scoped to one instance — created per imperative
|
|
547
|
+
* mount and per rebuild (updateProps). Mutations emit ops onto the instance's
|
|
548
|
+
* queue immediately; reads are served from the shadow tree.
|
|
549
|
+
*/
|
|
550
|
+
/**
|
|
551
|
+
* instance key → the proxy document for that instance. `createProxyDocument` is
|
|
552
|
+
* the only creator — imperative apps get one at mount, React mounts lazily
|
|
553
|
+
* via `getPublicInstance` — so global `document`/`window`/`Element` can
|
|
554
|
+
* dispatch to the right instance's document (see installInstanceDispatcher).
|
|
555
|
+
*/
|
|
556
|
+
const instanceDocs = new Map<string, InternalDocument>();
|
|
557
|
+
|
|
558
|
+
export const createProxyDocument = (instance: string): ProxyDocument => {
|
|
559
|
+
const doc = new InternalDocument(instance);
|
|
560
|
+
instanceDocs.set(instance, doc);
|
|
561
|
+
return doc;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The instance's proxy document, creating it lazily — the getPublicInstance
|
|
566
|
+
* path (React mounts only need a document when a library holds a ref or a
|
|
567
|
+
* portal target).
|
|
568
|
+
*/
|
|
569
|
+
export const docForInstance = (instance: string): InternalDocument =>
|
|
570
|
+
instanceDocs.get(instance) ?? (createProxyDocument(instance) as InternalDocument);
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The document `globalThis.document` should mean right now: the active
|
|
574
|
+
* instance's when a task holds one; the single registered document when only
|
|
575
|
+
* one instance exists (every real island worker — timers and promise
|
|
576
|
+
* continuations run outside instance tasks but unambiguously belong to it);
|
|
577
|
+
* the most recently active instance's otherwise (multi-instance in-process tests
|
|
578
|
+
* still route deferred library callbacks sensibly).
|
|
579
|
+
*/
|
|
580
|
+
export const activeInstanceDoc = (): InternalDocument | undefined => instanceDocs.get(getActiveInstance());
|
|
581
|
+
|
|
582
|
+
/** The ambient document: the sole registered doc, or the most recently
|
|
583
|
+
* active instance's — for out-of-task readers (timers, continuations). */
|
|
584
|
+
export const ambientDoc = (): InternalDocument | undefined =>
|
|
585
|
+
instanceDocs.size === 1
|
|
586
|
+
? instanceDocs.values().next().value
|
|
587
|
+
: instanceDocs.get(getLastActiveInstance());
|
|
588
|
+
|
|
589
|
+
|