@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,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allocId,
|
|
3
|
+
bumpOpsVersion,
|
|
4
|
+
getActiveInstance,
|
|
5
|
+
getLastActiveInstance,
|
|
6
|
+
getInstanceSize,
|
|
7
|
+
instances,
|
|
8
|
+
markInstanceActive,
|
|
9
|
+
pushOp,
|
|
10
|
+
registerHandler,
|
|
11
|
+
unregisterHandler,
|
|
12
|
+
} from '../instance';
|
|
13
|
+
import type { ElementInstance, HostInstance, TextInstance } from '../instance';
|
|
14
|
+
import type { EventPayload, Op } from '../../ops';
|
|
15
|
+
import { parseDocument, ElementType } from 'htmlparser2';
|
|
16
|
+
|
|
17
|
+
import { ProxyNode, ProxyText, ProxyComment, hyphenate } from './node';
|
|
18
|
+
import { ProxyElement } from './element';
|
|
19
|
+
import type { InternalDocument } from './document';
|
|
20
|
+
|
|
21
|
+
/* ── HTML: parse (htmlparser2) + serialize (shadow tree) ───────────────── */
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse an HTML fragment into fresh proxy nodes — one createElement/
|
|
25
|
+
* createTextNode/appendChild chain per parsed node, so setting innerHTML or
|
|
26
|
+
* calling insertAdjacentHTML emits the same op stream hand-built code
|
|
27
|
+
* would. htmlparser2 is pure JS — no DOM, worker-safe.
|
|
28
|
+
*/
|
|
29
|
+
/** A node in htmlparser2's parse tree — typed off the public API so no
|
|
30
|
+
* transitive domhandler import is needed. */
|
|
31
|
+
type ParsedNode = ReturnType<typeof parseDocument>['children'][number];
|
|
32
|
+
|
|
33
|
+
export function parseChildren(doc: InternalDocument, html: string): ProxyNode[] {
|
|
34
|
+
const parsed = parseDocument(String(html));
|
|
35
|
+
const build = (nodes: readonly ParsedNode[]): ProxyNode[] => {
|
|
36
|
+
const out: ProxyNode[] = [];
|
|
37
|
+
for (const node of nodes) {
|
|
38
|
+
switch (node.type) {
|
|
39
|
+
case ElementType.Tag:
|
|
40
|
+
case ElementType.Script:
|
|
41
|
+
case ElementType.Style: {
|
|
42
|
+
const el = doc.createElement(node.name);
|
|
43
|
+
for (const [name, value] of Object.entries(node.attribs)) el.setAttribute(name, value);
|
|
44
|
+
for (const child of build(node.children)) el.appendChild(child);
|
|
45
|
+
out.push(el);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case ElementType.Text: {
|
|
49
|
+
if (node.data !== '') out.push(doc.createTextNode(node.data));
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
case ElementType.Comment: {
|
|
53
|
+
// Comments materialize as ProxyComment — framework anchors baked
|
|
54
|
+
// into template HTML (Svelte's `<!>`/`<!--x-->` block boundaries,
|
|
55
|
+
// Vue's v-if anchors) survive the parse. Directives and doctype
|
|
56
|
+
// stay skipped.
|
|
57
|
+
out.push(doc.createComment(node.data));
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case ElementType.CDATA: {
|
|
61
|
+
// CDATA carries its text as children — flatten like the DOM does.
|
|
62
|
+
for (const child of build(node.children)) out.push(child);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
default:
|
|
66
|
+
break; // directives/doctype: skipped.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
};
|
|
71
|
+
return build(parsed.children);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Elements the HTML serializer emits without an end tag. */
|
|
75
|
+
const VOID_ELEMENTS = new Set([
|
|
76
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
77
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const escapeText = (s: string): string =>
|
|
81
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
82
|
+
const escapeAttr = (s: string): string =>
|
|
83
|
+
s.replace(/&/g, '&').replace(/"/g, '"');
|
|
84
|
+
|
|
85
|
+
/** Serialize a shadow node — elements with attrs (+ inline style from the
|
|
86
|
+
* style proxy), text escaped, comments round-tripping as `<!--data-->`.
|
|
87
|
+
* Reads the shadow tree only; nothing the proxy didn't write appears here. */
|
|
88
|
+
export function serializeNode(node: ProxyNode): string {
|
|
89
|
+
// ProxyComment extends ProxyText — check it FIRST so comment data doesn't
|
|
90
|
+
// serialize as literal text.
|
|
91
|
+
if (node instanceof ProxyComment) return `<!--${node.data}-->`;
|
|
92
|
+
if (node instanceof ProxyText) return escapeText(node.textContent);
|
|
93
|
+
if (!(node instanceof ProxyElement)) return '';
|
|
94
|
+
const tag = node.instance.type;
|
|
95
|
+
const attrs = new Map(node._attrs);
|
|
96
|
+
const styleText = Object.entries(node._styleProps)
|
|
97
|
+
.map(([k, v]) => `${hyphenate(k)}: ${v};`)
|
|
98
|
+
.join(' ');
|
|
99
|
+
if (styleText !== '') attrs.set('style', styleText);
|
|
100
|
+
let out = `<${tag}`;
|
|
101
|
+
for (const [name, value] of attrs) out += ` ${name}="${escapeAttr(value)}"`;
|
|
102
|
+
if (VOID_ELEMENTS.has(tag)) return `${out}>`;
|
|
103
|
+
out += '>';
|
|
104
|
+
for (const child of node._children) out += serializeNode(child);
|
|
105
|
+
return `${out}</${tag}>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allocId,
|
|
3
|
+
bumpOpsVersion,
|
|
4
|
+
getActiveInstance,
|
|
5
|
+
getLastActiveInstance,
|
|
6
|
+
getInstanceSize,
|
|
7
|
+
instances,
|
|
8
|
+
markInstanceActive,
|
|
9
|
+
pushOp,
|
|
10
|
+
registerHandler,
|
|
11
|
+
unregisterHandler,
|
|
12
|
+
} from '../instance';
|
|
13
|
+
import type { ElementInstance, HostInstance, TextInstance } from '../instance';
|
|
14
|
+
import type { EventPayload, Op } from '../../ops';
|
|
15
|
+
|
|
16
|
+
import type { InternalDocument, ProxyDocument } from './document';
|
|
17
|
+
|
|
18
|
+
/** Handler signature for proxy addEventListener — the wire payload plus a
|
|
19
|
+
* synthesized `target` (the proxy node for `targetId`, when known).
|
|
20
|
+
* `this` is bound to the node/document/facade the listener was attached
|
|
21
|
+
* to, like the real DOM's currentTarget. */
|
|
22
|
+
export type ProxyEventHandler = (this: unknown, payload: EventPayload) => void;
|
|
23
|
+
|
|
24
|
+
/** The subset of AddEventListenerOptions the `listen`/`unlisten` ops carry. */
|
|
25
|
+
export interface WireListenerOpts {
|
|
26
|
+
once?: boolean;
|
|
27
|
+
passive?: boolean;
|
|
28
|
+
capture?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Normalize the DOM's `options | captureFlag` argument into concrete flags. */
|
|
32
|
+
export const normalizeListenerOptions = (
|
|
33
|
+
options?: boolean | AddEventListenerOptions | EventListenerOptions | null,
|
|
34
|
+
): { once: boolean; passive: boolean; capture: boolean } => {
|
|
35
|
+
const o = (typeof options === 'boolean' ? { capture: options } : (options ?? {})) as AddEventListenerOptions;
|
|
36
|
+
return { once: o.once === true, passive: o.passive === true, capture: o.capture === true };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The `opts` field for a listen/unlisten op — undefined when every flag is
|
|
40
|
+
* off so plain listeners emit the same op shape as before. */
|
|
41
|
+
export const listenerOptsForWire = (
|
|
42
|
+
options?: boolean | AddEventListenerOptions | EventListenerOptions | null,
|
|
43
|
+
): WireListenerOpts | undefined => {
|
|
44
|
+
const o = normalizeListenerOptions(options);
|
|
45
|
+
return o.once || o.passive || o.capture
|
|
46
|
+
? { once: o.once, passive: o.passive, capture: o.capture }
|
|
47
|
+
: undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const hyphenate = (k: string): string => k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
51
|
+
export const camelize = (k: string): string => k.replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Phantom instance ids — negative, so they can never collide with allocId's
|
|
55
|
+
* positive ids and are never registered in `instances`. A phantom is the
|
|
56
|
+
* synthetic text child an element's `textContent` setter leaves in the
|
|
57
|
+
* shadow tree (the real DOM gets the same effect from `utext` on the
|
|
58
|
+
* parent, which is why phantom mutations retarget the parent — see below).
|
|
59
|
+
*/
|
|
60
|
+
let nextPhantomId = -1;
|
|
61
|
+
export const allocPhantomId = (): number => nextPhantomId--;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A node that isn't a ProxyNode can only arrive via a foreign `document`
|
|
65
|
+
* (the dispatcher's real-DOM fallback or a poisoned override) — typically a
|
|
66
|
+
* library's deferred callback running after its instance was torn down, or
|
|
67
|
+
* globals restored while worker-side timers still fire. Adopting it would
|
|
68
|
+
* crash later on `child.instance`; reject it here with the actual cause.
|
|
69
|
+
*/
|
|
70
|
+
export const rejectForeignChild = (child: unknown): never => {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'proxyDom.insertBefore: child is not a proxy node — the ambient `document`/' +
|
|
73
|
+
'`createElement` resolved a foreign DOM (a stale override or the real one), ' +
|
|
74
|
+
'so this node was created outside the instance that owns the parent',
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Detach `child` from its recorded parent, tolerating a stale/foreign
|
|
80
|
+
* `_parent` link (undefined, or a non-proxy object) instead of crashing on
|
|
81
|
+
* `_detach` — a corrupted link is cleared, a valid one is honored.
|
|
82
|
+
*/
|
|
83
|
+
export const detachFromParent = (child: ProxyNode): void => {
|
|
84
|
+
const p = child._parent;
|
|
85
|
+
if (p instanceof ProxyNode) p._detach(child);
|
|
86
|
+
else child._parent = null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Strings become text nodes — the ChildNode/ParentNode convenience arg. */
|
|
90
|
+
const asChild = (doc: InternalDocument, n: ProxyNode | string): ProxyNode =>
|
|
91
|
+
typeof n === 'string' ? doc.createTextNode(n) : n;
|
|
92
|
+
|
|
93
|
+
/* ── ProxyNode ─────────────────────────────────────────────────────────── */
|
|
94
|
+
|
|
95
|
+
export class ProxyNode {
|
|
96
|
+
/** The shared host-instance record — same objects `instances` holds. */
|
|
97
|
+
readonly instance: HostInstance;
|
|
98
|
+
/** The document that created/wrapped this node (internal fields below). */
|
|
99
|
+
readonly doc: InternalDocument;
|
|
100
|
+
_parent: ProxyNode | null = null;
|
|
101
|
+
_children: ProxyNode[] = [];
|
|
102
|
+
|
|
103
|
+
constructor(doc: InternalDocument, instance: HostInstance) {
|
|
104
|
+
this.doc = doc;
|
|
105
|
+
this.instance = instance;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get nodeType(): number {
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* '#text'/'#comment'/'#document-fragment' for character/fragment nodes;
|
|
114
|
+
* the tag name for elements — uppercase in the HTML namespace, verbatim
|
|
115
|
+
* for namespaced elements (svg 'foreignObject' keeps its case, matching
|
|
116
|
+
* the real DOM).
|
|
117
|
+
*/
|
|
118
|
+
get nodeName(): string {
|
|
119
|
+
switch (this.nodeType) {
|
|
120
|
+
case 1: {
|
|
121
|
+
const inst = this.instance as ElementInstance;
|
|
122
|
+
return inst.ns === undefined ? inst.type.toUpperCase() : inst.type;
|
|
123
|
+
}
|
|
124
|
+
case 3:
|
|
125
|
+
return '#text';
|
|
126
|
+
case 8:
|
|
127
|
+
return '#comment';
|
|
128
|
+
case 11:
|
|
129
|
+
return '#document-fragment';
|
|
130
|
+
default:
|
|
131
|
+
return '#node';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The proxy document that owns this node — what real DOM code reads as
|
|
136
|
+
* `el.ownerDocument`. */
|
|
137
|
+
get ownerDocument(): ProxyDocument {
|
|
138
|
+
return this.doc;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
get parentNode(): ProxyNode | null {
|
|
142
|
+
return this._parent;
|
|
143
|
+
}
|
|
144
|
+
get parentElement(): ProxyNode | null {
|
|
145
|
+
// structural Element check — importing ProxyElement here would create a
|
|
146
|
+
// module cycle because ProxyElement extends ProxyNode.
|
|
147
|
+
return this._parent !== null && this._parent.nodeType === 1 ? this._parent : null;
|
|
148
|
+
}
|
|
149
|
+
/** A copy of the shadow child list — structural changes go through
|
|
150
|
+
* appendChild/insertBefore/removeChild so ops stay in sync. */
|
|
151
|
+
get childNodes(): ProxyNode[] {
|
|
152
|
+
return this._children.slice();
|
|
153
|
+
}
|
|
154
|
+
get firstChild(): ProxyNode | null {
|
|
155
|
+
return this._children[0] ?? null;
|
|
156
|
+
}
|
|
157
|
+
get lastChild(): ProxyNode | null {
|
|
158
|
+
return this._children[this._children.length - 1] ?? null;
|
|
159
|
+
}
|
|
160
|
+
get nextSibling(): ProxyNode | null {
|
|
161
|
+
const p = this._parent;
|
|
162
|
+
if (p === null) return null;
|
|
163
|
+
return p._children[p._children.indexOf(this) + 1] ?? null;
|
|
164
|
+
}
|
|
165
|
+
get previousSibling(): ProxyNode | null {
|
|
166
|
+
const p = this._parent;
|
|
167
|
+
if (p === null) return null;
|
|
168
|
+
const i = p._children.indexOf(this);
|
|
169
|
+
return i > 0 ? p._children[i - 1] : null;
|
|
170
|
+
}
|
|
171
|
+
/** Attached to the document's root (driver-side id 0 container)? */
|
|
172
|
+
get isConnected(): boolean {
|
|
173
|
+
let n: ProxyNode | null = this;
|
|
174
|
+
while (n !== null) {
|
|
175
|
+
if (n === this.doc._root) return true;
|
|
176
|
+
n = n._parent;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The topmost shadow-tree ancestor — the root container element when the
|
|
183
|
+
* node is connected. (The facade has no separate Document node object;
|
|
184
|
+
* `doc.body`/`documentElement` ARE that root.)
|
|
185
|
+
*/
|
|
186
|
+
getRootNode(): ProxyNode {
|
|
187
|
+
let n: ProxyNode = this;
|
|
188
|
+
while (n._parent !== null) n = n._parent;
|
|
189
|
+
return n;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Concatenated descendant text — walked from the shadow tree. Comments
|
|
193
|
+
* are excluded: the DOM's descendant text content is Text-node data only. */
|
|
194
|
+
get textContent(): string {
|
|
195
|
+
let out = '';
|
|
196
|
+
for (const child of this._children) {
|
|
197
|
+
if (child.nodeType !== 8) out += child.textContent;
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
set textContent(value: string) {
|
|
202
|
+
this.doc._assertAlive();
|
|
203
|
+
const text = String(value);
|
|
204
|
+
for (const child of this._children) child._parent = null;
|
|
205
|
+
this._children = text === '' ? [] : [this.doc._phantomText(this, text)];
|
|
206
|
+
this._op({ t: 'utext', id: this.instance.id, text });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
contains(other: ProxyNode): boolean {
|
|
210
|
+
let n: ProxyNode | null = other;
|
|
211
|
+
while (n !== null) {
|
|
212
|
+
if (n === this) return true;
|
|
213
|
+
n = n._parent;
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
appendChild<T extends ProxyNode>(child: T): T {
|
|
219
|
+
return this.insertBefore(child, null);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
insertBefore<T extends ProxyNode>(child: T, ref: ProxyNode | null): T {
|
|
223
|
+
this.doc._assertAlive();
|
|
224
|
+
// Character-data nodes can't hold children — the driver replays every
|
|
225
|
+
// emitted append as a real insertBefore, which would throw
|
|
226
|
+
// HierarchyRequestError on the page; fail here first, like the DOM.
|
|
227
|
+
if (this.nodeType === 3 || this.nodeType === 8) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`proxyDom.insertBefore: ${this.nodeName} nodes cannot have children`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (!(child instanceof ProxyNode)) rejectForeignChild(child);
|
|
233
|
+
// A fragment is a phantom PARENT — the real DOM splices its children
|
|
234
|
+
// in at the insertion point and empties it; do the same (per-child ops).
|
|
235
|
+
if (child instanceof ProxyFragment) {
|
|
236
|
+
for (const c of child._children.slice()) this.insertBefore(c, ref);
|
|
237
|
+
return child;
|
|
238
|
+
}
|
|
239
|
+
let beforeId: number | undefined;
|
|
240
|
+
if (ref === null) {
|
|
241
|
+
detachFromParent(child);
|
|
242
|
+
child._parent = this;
|
|
243
|
+
this._children.push(child);
|
|
244
|
+
} else {
|
|
245
|
+
const index = this._children.indexOf(ref);
|
|
246
|
+
if (index === -1) {
|
|
247
|
+
throw new Error('proxyDom.insertBefore: reference node is not a child of this node');
|
|
248
|
+
}
|
|
249
|
+
detachFromParent(child);
|
|
250
|
+
child._parent = this;
|
|
251
|
+
this._children.splice(index, 0, child);
|
|
252
|
+
if (ref.instance.id > 0) beforeId = ref.instance.id;
|
|
253
|
+
}
|
|
254
|
+
// Phantom nodes (fragments, shadow-only texts) have NEGATIVE ids and no
|
|
255
|
+
// driver-side counterpart — skip ops when either endpoint is phantom.
|
|
256
|
+
// Id 0 is NOT phantom: it's the island's real root container.
|
|
257
|
+
if (this.instance.id >= 0 && child.instance.id > 0) {
|
|
258
|
+
this._op({ t: 'append', parent: this.instance.id, child: child.instance.id, before: beforeId });
|
|
259
|
+
}
|
|
260
|
+
return child;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
removeChild<T extends ProxyNode>(child: T): T {
|
|
264
|
+
this.doc._assertAlive();
|
|
265
|
+
const index = this._children.indexOf(child);
|
|
266
|
+
if (index === -1) {
|
|
267
|
+
throw new Error('proxyDom.removeChild: node is not a child of this node');
|
|
268
|
+
}
|
|
269
|
+
this._children.splice(index, 1);
|
|
270
|
+
child._parent = null;
|
|
271
|
+
if (this.instance.id >= 0 && child.instance.id > 0) {
|
|
272
|
+
this._op({ t: 'remove', child: child.instance.id });
|
|
273
|
+
}
|
|
274
|
+
return child;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Detach from the parent — no-op when already orphaned (like the DOM). */
|
|
278
|
+
remove(): void {
|
|
279
|
+
this._parent?.removeChild(this);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* ── ChildNode / ParentNode conveniences ───────────────────────────────
|
|
283
|
+
* Strings become text nodes like the DOM. before/after/replaceWith on a
|
|
284
|
+
* parentless node are no-ops (per spec). append/prepend/replaceChildren
|
|
285
|
+
* live on the base so elements AND fragments share them — real DOM also
|
|
286
|
+
* exposes them on DocumentFragment. */
|
|
287
|
+
|
|
288
|
+
before(...nodes: Array<ProxyNode | string>): void {
|
|
289
|
+
const p = this._parent;
|
|
290
|
+
if (p === null) return;
|
|
291
|
+
for (const n of nodes) p.insertBefore(asChild(this.doc, n), this);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
after(...nodes: Array<ProxyNode | string>): void {
|
|
295
|
+
const p = this._parent;
|
|
296
|
+
if (p === null) return;
|
|
297
|
+
const ref = this.nextSibling;
|
|
298
|
+
for (const n of nodes) p.insertBefore(asChild(this.doc, n), ref);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
replaceWith(...nodes: Array<ProxyNode | string>): void {
|
|
302
|
+
const p = this._parent;
|
|
303
|
+
if (p === null) return;
|
|
304
|
+
for (const n of nodes) p.insertBefore(asChild(this.doc, n), this);
|
|
305
|
+
p.removeChild(this);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** append(...nodes) — strings become text nodes, like the DOM. */
|
|
309
|
+
append(...nodes: Array<ProxyNode | string>): void {
|
|
310
|
+
this.doc._assertAlive();
|
|
311
|
+
for (const n of nodes) this.appendChild(asChild(this.doc, n));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** prepend(...nodes) — inserts at the front, in argument order. */
|
|
315
|
+
prepend(...nodes: Array<ProxyNode | string>): void {
|
|
316
|
+
this.doc._assertAlive();
|
|
317
|
+
const ref = this.firstChild;
|
|
318
|
+
for (const n of nodes) this.insertBefore(asChild(this.doc, n), ref);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** replaceChildren(...nodes) — remove every child, then append the set. */
|
|
322
|
+
replaceChildren(...nodes: Array<ProxyNode | string>): void {
|
|
323
|
+
this.doc._assertAlive();
|
|
324
|
+
for (const child of this._children.slice()) this.removeChild(child);
|
|
325
|
+
this.append(...nodes);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Structural clone — each concrete kind implements it. The copy is a
|
|
330
|
+
* FRESH instance (own id, own create op), not a shared record; listeners
|
|
331
|
+
* never cross, matching the DOM.
|
|
332
|
+
*/
|
|
333
|
+
cloneNode(_deep?: boolean): ProxyNode {
|
|
334
|
+
throw new Error('proxyDom.cloneNode: unsupported node kind');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Queue an op on this node's instance — the same routing hostConfig uses for
|
|
339
|
+
* instance-bound ops. Outside a instance task the op still lands on the right
|
|
340
|
+
* queue, and the doorbell is bumped so push-mode islands pick it up.
|
|
341
|
+
*/
|
|
342
|
+
_op(op: Op): void {
|
|
343
|
+
pushOp(this.instance.instance, op);
|
|
344
|
+
if (getActiveInstance() !== this.instance.instance) bumpOpsVersion();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Detach a child from the shadow list WITHOUT emitting an op — used when
|
|
348
|
+
* a following append op will move the real node on the main thread. */
|
|
349
|
+
_detach(child: ProxyNode): void {
|
|
350
|
+
const i = this._children.indexOf(child);
|
|
351
|
+
if (i !== -1) this._children.splice(i, 1);
|
|
352
|
+
child._parent = null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* ── ProxyText ─────────────────────────────────────────────────────────── */
|
|
357
|
+
|
|
358
|
+
export class ProxyText extends ProxyNode {
|
|
359
|
+
declare readonly instance: TextInstance;
|
|
360
|
+
|
|
361
|
+
constructor(doc: InternalDocument, instance: TextInstance) {
|
|
362
|
+
super(doc, instance);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
override get nodeType(): number {
|
|
366
|
+
return 3;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The driver-side id a text write targets. Real text instances write
|
|
371
|
+
* their own id; phantoms (created by an element's `textContent` setter)
|
|
372
|
+
* have no driver-side node, so writes retarget the parent — whose real
|
|
373
|
+
* `textContent` IS this node's whole text.
|
|
374
|
+
*/
|
|
375
|
+
get _utextTarget(): number {
|
|
376
|
+
return this.instance.id > 0
|
|
377
|
+
? this.instance.id
|
|
378
|
+
: (this._parent?.instance.id ?? this.instance.id);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
get data(): string {
|
|
382
|
+
return this.instance.text;
|
|
383
|
+
}
|
|
384
|
+
set data(v: string) {
|
|
385
|
+
this._write(v);
|
|
386
|
+
}
|
|
387
|
+
get nodeValue(): string {
|
|
388
|
+
return this.instance.text;
|
|
389
|
+
}
|
|
390
|
+
set nodeValue(v: string) {
|
|
391
|
+
this._write(v);
|
|
392
|
+
}
|
|
393
|
+
override get textContent(): string {
|
|
394
|
+
return this.instance.text;
|
|
395
|
+
}
|
|
396
|
+
override set textContent(v: string) {
|
|
397
|
+
this._write(v);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private _write(v: string): void {
|
|
401
|
+
this.doc._assertAlive();
|
|
402
|
+
const text = String(v);
|
|
403
|
+
this.instance.text = text;
|
|
404
|
+
this._op({ t: 'utext', id: this._utextTarget, text });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Fresh text node — the clone is a new `text` op, not a shared record. */
|
|
408
|
+
override cloneNode(_deep?: boolean): ProxyText {
|
|
409
|
+
return this.doc.createTextNode(this.instance.text);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* ── ProxyComment ────────────────────────────────────────────────────── */
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Comment — nodeType 8, backed by a REAL empty text node driver-side: the
|
|
417
|
+
* wire has no comment op, so `createComment` emits a `text` op with ''
|
|
418
|
+
* giving the node a `before:`-addressable position for anchors (Svelte's
|
|
419
|
+
* `<!>` block boundaries, Vue/Angular comment anchors).
|
|
420
|
+
*
|
|
421
|
+
* `data`/`nodeValue`/`textContent` writes update the shadow record ONLY —
|
|
422
|
+
* emitting `utext` would render the comment's data as visible page text.
|
|
423
|
+
*/
|
|
424
|
+
export class ProxyComment extends ProxyText {
|
|
425
|
+
override get nodeType(): number {
|
|
426
|
+
return 8;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
override get data(): string {
|
|
430
|
+
return this.instance.text;
|
|
431
|
+
}
|
|
432
|
+
override set data(v: string) {
|
|
433
|
+
this._shadowWrite(v);
|
|
434
|
+
}
|
|
435
|
+
override get nodeValue(): string {
|
|
436
|
+
return this.instance.text;
|
|
437
|
+
}
|
|
438
|
+
override set nodeValue(v: string) {
|
|
439
|
+
this._shadowWrite(v);
|
|
440
|
+
}
|
|
441
|
+
override get textContent(): string {
|
|
442
|
+
return this.instance.text;
|
|
443
|
+
}
|
|
444
|
+
override set textContent(v: string) {
|
|
445
|
+
this._shadowWrite(v);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private _shadowWrite(v: string): void {
|
|
449
|
+
this.doc._assertAlive();
|
|
450
|
+
this.instance.text = String(v);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
override cloneNode(_deep?: boolean): ProxyComment {
|
|
454
|
+
return this.doc.createComment(this.data);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* ── ProxyFragment ─────────────────────────────────────────────────────── */
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* DocumentFragment — a PHANTOM parent: it never exists driver-side, so its
|
|
462
|
+
* `instance.id` is negative (no ops ever target it). Appending it to a real
|
|
463
|
+
* parent splices its children out like the DOM does; libraries use it to
|
|
464
|
+
* batch insertions (Leaflet's GridLayer builds each zoom level in one).
|
|
465
|
+
*/
|
|
466
|
+
export class ProxyFragment extends ProxyNode {
|
|
467
|
+
override get nodeType(): number {
|
|
468
|
+
return 11;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Fresh fragment; `deep` clones children polymorphically so comment
|
|
472
|
+
* anchors keep nodeType 8. */
|
|
473
|
+
cloneNode(deep?: boolean): ProxyFragment {
|
|
474
|
+
const copy = this.doc.createDocumentFragment();
|
|
475
|
+
if (deep === true) {
|
|
476
|
+
for (const child of this._children) copy.appendChild(child.cloneNode(true));
|
|
477
|
+
}
|
|
478
|
+
return copy;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { ProxyElement } from './element';
|
|
2
|
+
import type { ProxyNode } from './node';
|
|
3
|
+
|
|
4
|
+
/* ── Selectors (compound + descendant combinator only) ─────────────────── */
|
|
5
|
+
|
|
6
|
+
interface CompoundSelector {
|
|
7
|
+
tag?: string;
|
|
8
|
+
id?: string;
|
|
9
|
+
classes: string[];
|
|
10
|
+
attrs: Array<{ name: string; value?: string }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const unsupportedSelector = (sel: string): never => {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`proxyDom.querySelector: unsupported selector "${sel}" — only tag, .class, #id, [attr], [attr="v"] and descendant combinators are supported`,
|
|
16
|
+
);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function parseCompound(part: string, sel: string): CompoundSelector {
|
|
20
|
+
const out: CompoundSelector = { classes: [], attrs: [] };
|
|
21
|
+
let i = 0;
|
|
22
|
+
const tag = /^[a-zA-Z][\w-]*/.exec(part);
|
|
23
|
+
if (tag !== null) {
|
|
24
|
+
out.tag = tag[0].toLowerCase();
|
|
25
|
+
i = tag[0].length;
|
|
26
|
+
}
|
|
27
|
+
while (i < part.length) {
|
|
28
|
+
const ch = part[i];
|
|
29
|
+
if (ch === '#') {
|
|
30
|
+
const m = /^[\w-]+/.exec(part.slice(i + 1));
|
|
31
|
+
if (m === null) unsupportedSelector(sel);
|
|
32
|
+
out.id = m![0];
|
|
33
|
+
i += 1 + m![0].length;
|
|
34
|
+
} else if (ch === '.') {
|
|
35
|
+
const m = /^[\w-]+/.exec(part.slice(i + 1));
|
|
36
|
+
if (m === null) unsupportedSelector(sel);
|
|
37
|
+
out.classes.push(m![0]);
|
|
38
|
+
i += 1 + m![0].length;
|
|
39
|
+
} else if (ch === '[') {
|
|
40
|
+
const end = part.indexOf(']', i);
|
|
41
|
+
if (end === -1) unsupportedSelector(sel);
|
|
42
|
+
const body = part.slice(i + 1, end).trim();
|
|
43
|
+
const m = /^([\w-]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^'"\s]+)))?$/.exec(body);
|
|
44
|
+
if (m === null) unsupportedSelector(sel);
|
|
45
|
+
out.attrs.push({ name: m![1], value: m![2] ?? m![3] ?? m![4] });
|
|
46
|
+
i = end + 1;
|
|
47
|
+
} else {
|
|
48
|
+
unsupportedSelector(sel);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (
|
|
52
|
+
out.tag === undefined &&
|
|
53
|
+
out.id === undefined &&
|
|
54
|
+
out.classes.length === 0 &&
|
|
55
|
+
out.attrs.length === 0
|
|
56
|
+
) {
|
|
57
|
+
unsupportedSelector(sel);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseSelector(selector: string): CompoundSelector[] {
|
|
63
|
+
const sel = selector.trim();
|
|
64
|
+
if (
|
|
65
|
+
sel === '' ||
|
|
66
|
+
sel.includes(',') ||
|
|
67
|
+
sel.includes('>') ||
|
|
68
|
+
sel.includes('+') ||
|
|
69
|
+
sel.includes('~') ||
|
|
70
|
+
sel.includes(':')
|
|
71
|
+
) {
|
|
72
|
+
unsupportedSelector(selector);
|
|
73
|
+
}
|
|
74
|
+
return sel.split(/\s+/).map((part) => parseCompound(part, selector));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function matchCompound(el: ProxyElement, c: CompoundSelector): boolean {
|
|
78
|
+
if (c.tag !== undefined && el.tagName.toLowerCase() !== c.tag) return false;
|
|
79
|
+
if (c.id !== undefined && el._attrs.get('id') !== c.id) return false;
|
|
80
|
+
for (const cls of c.classes) if (!el._classes.has(cls)) return false;
|
|
81
|
+
for (const a of c.attrs) {
|
|
82
|
+
const v = el._attrs.get(a.name);
|
|
83
|
+
if (v === undefined) return false;
|
|
84
|
+
if (a.value !== undefined && v !== a.value) return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `el` matches the last compound; each earlier compound must match some
|
|
90
|
+
* ancestor above the previous match — the descendant combinator. */
|
|
91
|
+
export function matchesChain(el: ProxyElement, chain: CompoundSelector[]): boolean {
|
|
92
|
+
if (!matchCompound(el, chain[chain.length - 1])) return false;
|
|
93
|
+
let cur: ProxyNode | null = el._parent;
|
|
94
|
+
for (let i = chain.length - 2; i >= 0; i--) {
|
|
95
|
+
let found = false;
|
|
96
|
+
while (cur !== null) {
|
|
97
|
+
if (cur instanceof ProxyElement && matchCompound(cur, chain[i])) {
|
|
98
|
+
found = true;
|
|
99
|
+
cur = cur._parent;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
cur = cur._parent;
|
|
103
|
+
}
|
|
104
|
+
if (!found) return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|