@atolljs/islands 0.1.7 → 0.1.8
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 +3 -2
- package/package.json +2 -2
- package/src/callbackProps.ts +4 -0
- package/src/devtoolsCommands.ts +258 -0
- package/src/devtoolsPreview.ts +46 -0
- package/src/island.ts +157 -33
- package/src/ops.ts +6 -0
- package/src/worker/defineWorkers.ts +18 -2
- package/src/worker/index.ts +17 -0
- package/src/worker/instance.ts +4 -0
- package/src/worker/subIsland.ts +619 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nested half of `mountIsland` — registered via
|
|
3
|
+
* `registerSubIslandMounter` so the ONE public function serves both mount
|
|
4
|
+
* targets: a real element takes the main-thread DOM driver, a proxy
|
|
5
|
+
* element lands here. This mounter spawns a REAL sub-worker (an ordinary
|
|
6
|
+
* `definePolyWorker`/`defineMonoWorker` entry) inside a worker-rendered
|
|
7
|
+
* island and replays its op batches into proxy DOM nodes of the parent
|
|
8
|
+
* instance's shadow tree.
|
|
9
|
+
*
|
|
10
|
+
* The trick that makes nesting need no new wire format: every proxy-DOM
|
|
11
|
+
* mutation already serializes to an op on the PARENT instance's queue —
|
|
12
|
+
* so replaying the sub-island's ops into proxy nodes tunnels the inner
|
|
13
|
+
* DOM upward through the parent's own op stream. The main thread sees one
|
|
14
|
+
* flat op batch and can't tell which worker produced it.
|
|
15
|
+
*
|
|
16
|
+
* main mountIsland ──ops──▶ real DOM
|
|
17
|
+
* ▲ ▲ (one hop: worker → main)
|
|
18
|
+
* │ └─ parent instance ops ── proxy mutations ──┐
|
|
19
|
+
* sub-island ops ──────── mountSubIsland (this file) ─┘
|
|
20
|
+
*
|
|
21
|
+
* Events travel the same path in reverse: a `listen`/`on*` handler on a
|
|
22
|
+
* replayed node becomes a proxy `addEventListener`, which emits a `listen`
|
|
23
|
+
* op to the main thread; the real event dispatches into THIS worker, the
|
|
24
|
+
* proxy listener forwards the payload down to the sub-worker's `dispatch`.
|
|
25
|
+
* Two hops end to end — the doorbell still delivers pushes, since async op
|
|
26
|
+
* application bumps the parent's `opsVersion` like any out-of-task commit.
|
|
27
|
+
*
|
|
28
|
+
* Instance scoping: the sub-instance key minted here is
|
|
29
|
+
* `parent~app@N` (`'dashboard@1~counter@1'`) — globally unique across the
|
|
30
|
+
* worker topology AND self-describing in devtools, where forwarded events
|
|
31
|
+
* already carry the emitting worker's stamp. `appNameOf` still resolves
|
|
32
|
+
* the registry name off the last '@'.
|
|
33
|
+
*/
|
|
34
|
+
import { devtoolsEnabled, emitDevtools, estimateCloneBytes, observe, previewValue } from '@atolljs/core';
|
|
35
|
+
import { propsPreview } from '../devtoolsPreview';
|
|
36
|
+
import {
|
|
37
|
+
assertCloneableProps,
|
|
38
|
+
connectIslandWorker,
|
|
39
|
+
cssStyleValue,
|
|
40
|
+
registerSubIslandMounter,
|
|
41
|
+
type ConnectIslandWorkerConfig,
|
|
42
|
+
type IslandClient,
|
|
43
|
+
type IslandHandle,
|
|
44
|
+
type Mode,
|
|
45
|
+
type MountIslandOptions,
|
|
46
|
+
} from '../island';
|
|
47
|
+
import { marshalCallbackProps, CALLBACK_EVENT } from '../callbackProps';
|
|
48
|
+
import { isEventRef } from '../ops';
|
|
49
|
+
import type { EventPayload, Op, WireProps } from '../ops';
|
|
50
|
+
import { getInstanceSize, runInInstance } from './instance';
|
|
51
|
+
import type { InternalDocument } from './dom/document';
|
|
52
|
+
import { ProxyElement } from './dom/element';
|
|
53
|
+
import type { ProxyEventHandler, ProxyNode } from './dom/node';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The surface is ONE function: `mountIsland`. A proxy `el` makes the mount
|
|
57
|
+
* nested — these are the same options either way. `slots` receive the
|
|
58
|
+
* anchor's ProxyElement (worker-side content portals into it); slot names
|
|
59
|
+
* this mount doesn't list bubble up to the outer island's `slots`.
|
|
60
|
+
*/
|
|
61
|
+
export type MountSubIslandOptions = MountIslandOptions;
|
|
62
|
+
/** One handle shape for both mount targets — `instance` reads `parent~app@N`. */
|
|
63
|
+
export type SubIslandHandle = IslandHandle;
|
|
64
|
+
|
|
65
|
+
/** Per-context mount counter — the `N` in `parent~app@N`. */
|
|
66
|
+
let subSeq = 0;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Live sub-island count per shared client — the same contract as
|
|
70
|
+
* mountIsland's `clientMounts`: `destroy()` releases the instance via
|
|
71
|
+
* `unmount` while siblings remain, and the LAST sub-island to leave
|
|
72
|
+
* terminates the sub-worker.
|
|
73
|
+
*/
|
|
74
|
+
const clientMounts = new WeakMap<object, number>();
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Enrichment keys the parent worker's proxy DOM synthesizes onto event
|
|
78
|
+
* payloads (see InternalDocument._enrichEvent) — they never cross a wire,
|
|
79
|
+
* so they're stripped before a payload is forwarded down to a sub-worker,
|
|
80
|
+
* which re-enriches `target`/`currentTarget` against ITS OWN shadow tree.
|
|
81
|
+
*/
|
|
82
|
+
const ENRICHED_KEYS = new Set([
|
|
83
|
+
'target',
|
|
84
|
+
'currentTarget',
|
|
85
|
+
'composedPath',
|
|
86
|
+
'preventDefault',
|
|
87
|
+
'stopPropagation',
|
|
88
|
+
'stopImmediatePropagation',
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIslandHandle> {
|
|
92
|
+
const {
|
|
93
|
+
el, onEvent, onActivity, onOps, slots, client: givenClient, worker, app: givenApp,
|
|
94
|
+
props: givenProps, mode: initialMode, framework, mountTimeout,
|
|
95
|
+
...workerConfig
|
|
96
|
+
} = opts;
|
|
97
|
+
if (!(el instanceof ProxyElement)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'mountSubIsland: `el` must be a ProxyElement of a mounted parent island — ' +
|
|
100
|
+
'a real element mounts through the main-thread driver (mountIsland handles both).',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const doc = el.doc as InternalDocument;
|
|
104
|
+
const parentInstance = doc.instance;
|
|
105
|
+
const app = givenApp ?? 'main';
|
|
106
|
+
// `~` separates nesting levels — 'dashboard@1~counter@1' reads as
|
|
107
|
+
// 'counter@1 mounted by dashboard@1'. appNameOf still resolves 'counter'.
|
|
108
|
+
const instance = `${parentInstance}~${app}@${++subSeq}`;
|
|
109
|
+
|
|
110
|
+
if (givenClient === undefined && worker === undefined) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
'mountSubIsland: pass `worker` (a `() => new Worker(...)`/URL entry — this call builds ' +
|
|
113
|
+
'the island-owned sub-client) or `client` (a shared connectIslandWorker — several ' +
|
|
114
|
+
'sub-islands, one sub-worker)',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// callbackProp() markers marshal to {__cb:id} wire handles — the SAME
|
|
119
|
+
// protocol the main driver uses, so worker-side functions can cross into
|
|
120
|
+
// the sub-worker and its invocations route back to this table.
|
|
121
|
+
const cbTable = new Map<number, (...args: unknown[]) => void>();
|
|
122
|
+
let cbSeq = 0;
|
|
123
|
+
const registerCb = (fn: (...args: unknown[]) => void): number => {
|
|
124
|
+
cbTable.set(++cbSeq, fn);
|
|
125
|
+
return cbSeq;
|
|
126
|
+
};
|
|
127
|
+
const props = marshalCallbackProps(givenProps ?? {}, registerCb) as Record<string, unknown>;
|
|
128
|
+
assertCloneableProps(props, `mountIsland(${givenApp ?? 'main'})`);
|
|
129
|
+
|
|
130
|
+
// Identical client shorthand to the main driver: workerConfig extras
|
|
131
|
+
// (concurrency, taskTimeout, respawn…) pass through; 'poll' builds the
|
|
132
|
+
// client doorbell-free — no SharedArrayBuffer inside the sub-worker.
|
|
133
|
+
const client: IslandClient =
|
|
134
|
+
givenClient ??
|
|
135
|
+
connectIslandWorker({
|
|
136
|
+
name: givenApp,
|
|
137
|
+
...workerConfig,
|
|
138
|
+
worker: worker!,
|
|
139
|
+
doorbell: initialMode !== 'poll',
|
|
140
|
+
} as ConnectIslandWorkerConfig);
|
|
141
|
+
clientMounts.set(client, (clientMounts.get(client) ?? 0) + 1);
|
|
142
|
+
|
|
143
|
+
/** sub-op node id → proxy node (0 is the container sentinel). */
|
|
144
|
+
const nodes = new Map<number, ProxyNode>([[0, el]]);
|
|
145
|
+
/** proxy node instance id → sub-op id — translates dispatched targetIds. */
|
|
146
|
+
const proxyIdToSub = new Map<number, number>([[el.instance.id, 0]]);
|
|
147
|
+
/** sub-id → last applied prop set (for `update` diffing). */
|
|
148
|
+
const prevProps = new Map<number, WireProps>();
|
|
149
|
+
/** sub-id → 'type#handler' → attached proxy listener (kept for removal). */
|
|
150
|
+
const nodeListeners = new Map<number, Map<string, { fn: ProxyEventHandler; capture?: boolean }>>();
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* sub-id → slot name for anchors THIS mount's `slots` claimed. A claimed
|
|
154
|
+
* anchor carries `data-atoll-sub-slot` instead of `data-atoll-slot`, so
|
|
155
|
+
* the main driver never hands it to the page shell as well; names the
|
|
156
|
+
* mount doesn't claim (`name in slots` is false) keep the plain attribute
|
|
157
|
+
* and surface on the outer island's `slots`, as before.
|
|
158
|
+
*/
|
|
159
|
+
const slotNodes = new Map<number, string>();
|
|
160
|
+
const claims = (name: string): boolean => slots !== undefined && name in slots;
|
|
161
|
+
function mountSlot(node: ProxyElement, subId: number, name: string): void {
|
|
162
|
+
node.setAttribute('data-atoll-sub-slot', name);
|
|
163
|
+
const prev = slotNodes.get(subId);
|
|
164
|
+
if (prev !== undefined && prev !== name) slots?.[prev]?.(null);
|
|
165
|
+
slotNodes.set(subId, name);
|
|
166
|
+
slots?.[name]?.(node as unknown as HTMLElement);
|
|
167
|
+
}
|
|
168
|
+
function unmountSlot(subId: number): void {
|
|
169
|
+
const name = slotNodes.get(subId);
|
|
170
|
+
if (name === undefined) return;
|
|
171
|
+
slotNodes.delete(subId);
|
|
172
|
+
slots?.[name]?.(null);
|
|
173
|
+
}
|
|
174
|
+
/** After a remove/clear: release every claimed anchor no longer under `el`. */
|
|
175
|
+
function releaseDetachedSlots(): void {
|
|
176
|
+
for (const subId of [...slotNodes.keys()]) {
|
|
177
|
+
const node = nodes.get(subId);
|
|
178
|
+
if (node === undefined || !(el as ProxyNode).contains(node)) unmountSlot(subId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let unsubscribe: (() => void) | null = null;
|
|
183
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
184
|
+
let destroyed = false;
|
|
185
|
+
let opsApplied = 0;
|
|
186
|
+
let flushCalls = 0;
|
|
187
|
+
let mode: Mode = initialMode ?? 'push';
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A DOM event dispatched into THIS worker, headed one level down: strip
|
|
191
|
+
* the parent-side enrichment and translate `targetId` from the parent's
|
|
192
|
+
* proxy-id space to the sub-worker's node-id space.
|
|
193
|
+
*/
|
|
194
|
+
function translatePayload(p: EventPayload): EventPayload {
|
|
195
|
+
const out: Record<string, unknown> = {};
|
|
196
|
+
for (const [k, v] of Object.entries(p)) {
|
|
197
|
+
if (!ENRICHED_KEYS.has(k)) out[k] = v;
|
|
198
|
+
}
|
|
199
|
+
if (typeof p.targetId === 'number') {
|
|
200
|
+
const subId = proxyIdToSub.get(p.targetId);
|
|
201
|
+
if (subId !== undefined) out.targetId = subId;
|
|
202
|
+
else delete out.targetId;
|
|
203
|
+
}
|
|
204
|
+
return out as unknown as EventPayload;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The proxy-side listener for a sub-worker's handler id — forwards the
|
|
208
|
+
* translated payload down and applies whatever ops come back. */
|
|
209
|
+
function dispatchListener(subHid: number): ProxyEventHandler {
|
|
210
|
+
return (payload) => {
|
|
211
|
+
void client
|
|
212
|
+
.dispatch(subHid, translatePayload(payload as EventPayload))
|
|
213
|
+
.then((ops) => applyOps(ops, 'dispatch'))
|
|
214
|
+
.catch((err) => console.error(`[sub-island ${instance}] dispatch failed`, err));
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function trackListener(subId: number, key: string, fn: ProxyEventHandler, capture?: boolean): void {
|
|
219
|
+
let table = nodeListeners.get(subId);
|
|
220
|
+
if (!table) nodeListeners.set(subId, (table = new Map()));
|
|
221
|
+
if (!table.has(key)) table.set(key, { fn, capture });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function setProp(node: ProxyElement, subId: number, name: string, value: unknown): void {
|
|
225
|
+
if (name === 'data-atoll-slot' && claims(String(value))) {
|
|
226
|
+
mountSlot(node, subId, String(value));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (isEventRef(value)) {
|
|
230
|
+
const eventName = name.slice(2).toLowerCase();
|
|
231
|
+
const key = `${eventName}#${value.__evt}`;
|
|
232
|
+
if (!(nodeListeners.get(subId)?.has(key))) {
|
|
233
|
+
const fn = dispatchListener(value.__evt);
|
|
234
|
+
trackListener(subId, key, fn);
|
|
235
|
+
node.addEventListener(eventName, fn);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (name === 'style' && typeof value === 'object' && value !== null) {
|
|
240
|
+
const next = value as Record<string, unknown>;
|
|
241
|
+
const prev = (prevProps.get(subId)?.style ?? {}) as Record<string, unknown>;
|
|
242
|
+
const style = node.style as unknown as Record<string, string>;
|
|
243
|
+
for (const k of Object.keys(prev)) if (!(k in next)) style[k] = '';
|
|
244
|
+
for (const [k, v] of Object.entries(next)) {
|
|
245
|
+
if (prev[k] !== v) style[k] = cssStyleValue(k, v);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (name === 'className') {
|
|
250
|
+
node.className = String(value);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (value === true) {
|
|
254
|
+
node.setAttribute(name, '');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (value === false) {
|
|
258
|
+
node.removeAttribute(name);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Reflected properties (value, checked, disabled, src, href, tabIndex…)
|
|
262
|
+
// emit attr ops through their accessors; everything else is a plain attr.
|
|
263
|
+
if (name in node) {
|
|
264
|
+
try {
|
|
265
|
+
(node as unknown as Record<string, unknown>)[name] = value;
|
|
266
|
+
return;
|
|
267
|
+
} catch {
|
|
268
|
+
/* read-only accessor — fall back to the attribute */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
node.setAttribute(name, String(value));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function removeProp(node: ProxyElement, subId: number, name: string, oldValue: unknown): void {
|
|
275
|
+
if (name === 'data-atoll-slot' && slotNodes.has(subId)) {
|
|
276
|
+
unmountSlot(subId);
|
|
277
|
+
node.removeAttribute('data-atoll-sub-slot');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (isEventRef(oldValue)) {
|
|
281
|
+
const eventName = name.slice(2).toLowerCase();
|
|
282
|
+
const key = `${eventName}#${oldValue.__evt}`;
|
|
283
|
+
const entry = nodeListeners.get(subId)?.get(key);
|
|
284
|
+
if (entry) {
|
|
285
|
+
node.removeEventListener(eventName, entry.fn, { capture: entry.capture });
|
|
286
|
+
nodeListeners.get(subId)?.delete(key);
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (name === 'style') {
|
|
291
|
+
for (const k of Object.keys((oldValue ?? {}) as Record<string, unknown>)) {
|
|
292
|
+
(node.style as unknown as Record<string, string>)[k] = '';
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (name === 'className') {
|
|
297
|
+
node.className = '';
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
node.removeAttribute(name);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function applyProps(node: ProxyElement, subId: number, prev: WireProps, next: WireProps): void {
|
|
304
|
+
for (const name of Object.keys(prev)) {
|
|
305
|
+
if (!(name in next)) removeProp(node, subId, name, prev[name]);
|
|
306
|
+
}
|
|
307
|
+
for (const [name, value] of Object.entries(next)) {
|
|
308
|
+
if (prev[name] !== value || isEventRef(value)) setProp(node, subId, name, value);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function applyOp(op: Op): void {
|
|
313
|
+
switch (op.t) {
|
|
314
|
+
case 'create': {
|
|
315
|
+
const node =
|
|
316
|
+
op.ns !== undefined ? doc.createElementNS(op.ns, op.type) : doc.createElement(op.type);
|
|
317
|
+
nodes.set(op.id, node);
|
|
318
|
+
proxyIdToSub.set(node.instance.id, op.id);
|
|
319
|
+
for (const [name, value] of Object.entries(op.props)) setProp(node, op.id, name, value);
|
|
320
|
+
prevProps.set(op.id, op.props);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case 'text': {
|
|
324
|
+
const node = doc.createTextNode(op.text);
|
|
325
|
+
nodes.set(op.id, node);
|
|
326
|
+
proxyIdToSub.set(node.instance.id, op.id);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case 'append': {
|
|
330
|
+
const parent = nodes.get(op.parent) as ProxyElement | undefined;
|
|
331
|
+
const child = nodes.get(op.child);
|
|
332
|
+
if (!parent || !child) {
|
|
333
|
+
console.error(
|
|
334
|
+
`[sub-island] append skipped: parent=${op.parent}→${String(parent)} child=${op.child}→${String(child)}`,
|
|
335
|
+
);
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
// `<template>` children share the .content shadow array — a plain
|
|
339
|
+
// insertBefore emits the template-id append the main driver routes
|
|
340
|
+
// into el.content.
|
|
341
|
+
const before = op.before !== undefined ? (nodes.get(op.before) ?? null) : null;
|
|
342
|
+
parent.insertBefore(child, before);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case 'remove': {
|
|
346
|
+
nodes.get(op.child)?.remove();
|
|
347
|
+
releaseDetachedSlots();
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case 'update': {
|
|
351
|
+
const node = nodes.get(op.id);
|
|
352
|
+
if (!(node instanceof ProxyElement)) break;
|
|
353
|
+
const prev = prevProps.get(op.id) ?? {};
|
|
354
|
+
applyProps(node, op.id, prev, op.props);
|
|
355
|
+
prevProps.set(op.id, op.props);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
case 'utext': {
|
|
359
|
+
const node = nodes.get(op.id);
|
|
360
|
+
if (node) node.textContent = op.text;
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
case 'attr': {
|
|
364
|
+
const node = nodes.get(op.id);
|
|
365
|
+
if (!(node instanceof ProxyElement)) break;
|
|
366
|
+
// A slot this mount claims stays local (see slotNodes); any other
|
|
367
|
+
// `data-atoll-slot` passes through unchanged, so the MAIN driver's
|
|
368
|
+
// slot machinery mounts it on the outer island's `slots`.
|
|
369
|
+
if (op.name === 'data-atoll-slot') {
|
|
370
|
+
if (op.value === null && slotNodes.has(op.id)) {
|
|
371
|
+
unmountSlot(op.id);
|
|
372
|
+
node.removeAttribute('data-atoll-sub-slot');
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
if (op.value !== null && claims(op.value)) {
|
|
376
|
+
mountSlot(node, op.id, op.value);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (op.value === null) node.removeAttribute(op.name);
|
|
381
|
+
else node.setAttribute(op.name, op.value);
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
case 'style': {
|
|
385
|
+
const node = nodes.get(op.id);
|
|
386
|
+
if (!(node instanceof ProxyElement)) break;
|
|
387
|
+
const style = node.style as unknown as Record<string, string>;
|
|
388
|
+
for (const [k, v] of Object.entries(op.props)) style[k] = v;
|
|
389
|
+
// Fold into prevProps so a later `update` prop-diff sees these keys
|
|
390
|
+
// as already applied (same bookkeeping as the main driver's
|
|
391
|
+
// mergeStyle) — otherwise a React style-object diff would re-clear
|
|
392
|
+
// keys the proxy-level style op just wrote.
|
|
393
|
+
const prev = prevProps.get(op.id) ?? {};
|
|
394
|
+
prev.style = { ...((prev.style ?? {}) as Record<string, string>), ...op.props };
|
|
395
|
+
prevProps.set(op.id, prev);
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
case 'listen': {
|
|
399
|
+
const node = nodes.get(op.id);
|
|
400
|
+
if (!(node instanceof ProxyElement) && op.id !== 0) break;
|
|
401
|
+
const target = (node ?? el) as ProxyElement;
|
|
402
|
+
const key = `${op.type}#${op.handler}`;
|
|
403
|
+
if (!(nodeListeners.get(op.id)?.has(key))) {
|
|
404
|
+
const fn = dispatchListener(op.handler);
|
|
405
|
+
trackListener(op.id, key, fn, op.opts?.capture);
|
|
406
|
+
target.addEventListener(op.type, fn, op.opts);
|
|
407
|
+
}
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case 'unlisten': {
|
|
411
|
+
const node = nodes.get(op.id) as ProxyElement | undefined;
|
|
412
|
+
if (!(node instanceof ProxyElement)) break;
|
|
413
|
+
const key = `${op.type}#${op.handler}`;
|
|
414
|
+
const entry = nodeListeners.get(op.id)?.get(key);
|
|
415
|
+
if (entry) {
|
|
416
|
+
node.removeEventListener(op.type, entry.fn, { capture: op.opts?.capture });
|
|
417
|
+
nodeListeners.get(op.id)?.delete(key);
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
case 'clear': {
|
|
422
|
+
el.replaceChildren();
|
|
423
|
+
releaseDetachedSlots();
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
case 'emit': {
|
|
427
|
+
if (op.name === CALLBACK_EVENT) {
|
|
428
|
+
const p = op.payload as { id?: number; args?: unknown[] } | undefined;
|
|
429
|
+
if (p !== null && typeof p === 'object' && typeof p.id === 'number') {
|
|
430
|
+
cbTable.get(p.id)?.(...(p.args ?? []));
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
if (devtoolsEnabled()) {
|
|
435
|
+
emitDevtools({ type: 'island:event', instance, name: op.name, payload: previewValue(op.payload, 1024) });
|
|
436
|
+
}
|
|
437
|
+
// Instance scope for the callback only — a re-emit() inside onEvent
|
|
438
|
+
// resolves the ambient instance and must land on the parent's queue.
|
|
439
|
+
runInInstance(parentInstance, () => onEvent?.(op.name, op.payload));
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'flush'): void {
|
|
446
|
+
opsApplied += ops.length;
|
|
447
|
+
onActivity?.();
|
|
448
|
+
const t0 = performance.now();
|
|
449
|
+
// No runInInstance wrap + no explicit bump: proxy mutations emit their
|
|
450
|
+
// own ops and `ProxyNode._op` already bumps the doorbell whenever the
|
|
451
|
+
// active instance differs (always true — applyOps runs in async
|
|
452
|
+
// continuations, never inside an instance task). A manual bump here
|
|
453
|
+
// would loop: doorbell → flush → empty batch → bump → doorbell → …
|
|
454
|
+
for (const op of ops) applyOp(op);
|
|
455
|
+
const replayMs = performance.now() - t0;
|
|
456
|
+
if (devtoolsEnabled()) {
|
|
457
|
+
emitDevtools({
|
|
458
|
+
type: 'island:ops', instance, via, count: ops.length, replayMs,
|
|
459
|
+
bytes: estimateCloneBytes(ops),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
onOps?.(ops, replayMs);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Times a worker→sub-worker round-trip for the devtools inspector. */
|
|
466
|
+
const islandCall = <T>(
|
|
467
|
+
method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount',
|
|
468
|
+
p: Promise<T>,
|
|
469
|
+
): Promise<T> => {
|
|
470
|
+
const t0 = performance.now();
|
|
471
|
+
return p.then(
|
|
472
|
+
(r) => {
|
|
473
|
+
emitDevtools({
|
|
474
|
+
type: 'island:task', instance, method,
|
|
475
|
+
ms: performance.now() - t0,
|
|
476
|
+
ops: Array.isArray(r) ? r.length : undefined,
|
|
477
|
+
});
|
|
478
|
+
return r;
|
|
479
|
+
},
|
|
480
|
+
(err) => {
|
|
481
|
+
emitDevtools({
|
|
482
|
+
type: 'island:task', instance, method,
|
|
483
|
+
ms: performance.now() - t0,
|
|
484
|
+
error: err instanceof Error ? err.message : String(err),
|
|
485
|
+
});
|
|
486
|
+
throw err;
|
|
487
|
+
},
|
|
488
|
+
);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const doFlush = (): void => {
|
|
492
|
+
if (destroyed) return;
|
|
493
|
+
flushCalls++;
|
|
494
|
+
void islandCall('flush', client.flush(instance)).then((ops) => applyOps(ops, 'flush'));
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
/* ── Transport: push (sub-pool's doorbell buffer) vs poll — same shape
|
|
498
|
+
* as the main driver. Observing shared memory inside a worker is legal
|
|
499
|
+
* (Atomics.waitAsync works off the main thread); 'poll' needs no SAB. */
|
|
500
|
+
const doorbell = client.sharedMemory ? observe(client.sharedMemory, 'opsVersion') : null;
|
|
501
|
+
|
|
502
|
+
function setMode(next: Mode): void {
|
|
503
|
+
mode = next;
|
|
504
|
+
unsubscribe?.();
|
|
505
|
+
unsubscribe = null;
|
|
506
|
+
if (pollTimer !== null) {
|
|
507
|
+
clearInterval(pollTimer);
|
|
508
|
+
pollTimer = null;
|
|
509
|
+
}
|
|
510
|
+
if (mode === 'push' && doorbell !== null) unsubscribe = doorbell.subscribe(doFlush);
|
|
511
|
+
else pollTimer = setInterval(doFlush, 50);
|
|
512
|
+
onActivity?.();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/* ── Mount handshake ─────────────────────────────────────────────────── */
|
|
516
|
+
const mountMs = mountTimeout ?? 15_000;
|
|
517
|
+
const raced = <T>(p: Promise<T>): Promise<T> => {
|
|
518
|
+
if (mountMs <= 0) return p;
|
|
519
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
520
|
+
return Promise.race([
|
|
521
|
+
p,
|
|
522
|
+
new Promise<never>((_, reject) => {
|
|
523
|
+
timer = setTimeout(
|
|
524
|
+
() =>
|
|
525
|
+
reject(
|
|
526
|
+
new Error(
|
|
527
|
+
`mountIsland(${app}): the sub-worker never answered within ${mountMs}ms — ` +
|
|
528
|
+
'check that the entry module loads and calls definePolyWorker/defineMonoWorker.',
|
|
529
|
+
),
|
|
530
|
+
),
|
|
531
|
+
mountMs,
|
|
532
|
+
);
|
|
533
|
+
}),
|
|
534
|
+
]).finally(() => clearTimeout(timer));
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
let pid: string;
|
|
538
|
+
try {
|
|
539
|
+
applyOps(await raced(islandCall('mount', client.mount(instance, props))), 'mount');
|
|
540
|
+
pid = await raced(islandCall('whoami', client.whoami(instance)));
|
|
541
|
+
if (devtoolsEnabled()) {
|
|
542
|
+
const fw = framework ?? (await client.renderer(instance).catch(() => null)) ?? undefined;
|
|
543
|
+
emitDevtools({
|
|
544
|
+
type: 'island:mount', instance, app, pid,
|
|
545
|
+
poolId: client.pool?.poolId, framework: fw,
|
|
546
|
+
});
|
|
547
|
+
emitDevtools({ type: 'island:props', instance, props: propsPreview(givenProps ?? {}) });
|
|
548
|
+
}
|
|
549
|
+
} catch (err) {
|
|
550
|
+
// The instance may exist sub-worker-side with ops never applied — an
|
|
551
|
+
// island-owned client terminates (a broken entry can't respawn-loop);
|
|
552
|
+
// a shared client just drops the instance.
|
|
553
|
+
clientMounts.set(client, (clientMounts.get(client) ?? 1) - 1);
|
|
554
|
+
if (givenClient === undefined) client.terminate();
|
|
555
|
+
else void islandCall('unmount', client.unmount(instance)).catch(() => {});
|
|
556
|
+
throw err;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Start the transport now that the handshake landed — same ordering as
|
|
560
|
+
// the main driver ('push' subscribes the doorbell, 'poll' the interval).
|
|
561
|
+
setMode(mode);
|
|
562
|
+
|
|
563
|
+
/* ── Pushed-size forwarding ─────────────────────────────────────────────
|
|
564
|
+
* The only geometry channel that exists is the parent island's measured
|
|
565
|
+
* box — forward it as the sub-island's container size (same claim
|
|
566
|
+
* `doc.markContainer` makes: "pretend my box is the container's"). Sub
|
|
567
|
+
* apps that genuinely fill their mount get a usable size; others see the
|
|
568
|
+
* parent's box — honest enough, and the alternative is permanent zeros. */
|
|
569
|
+
const pushSize = (w: number, h: number): void => {
|
|
570
|
+
if (destroyed) return;
|
|
571
|
+
void islandCall('setSize', client.setSize(instance, w, h)).then((ops) => applyOps(ops, 'setSize'));
|
|
572
|
+
};
|
|
573
|
+
doc.onResize(pushSize);
|
|
574
|
+
const initial = getInstanceSize(parentInstance);
|
|
575
|
+
if (initial !== undefined) pushSize(initial.w, initial.h);
|
|
576
|
+
|
|
577
|
+
const handle: IslandHandle = {
|
|
578
|
+
app,
|
|
579
|
+
pid,
|
|
580
|
+
instance,
|
|
581
|
+
get mode() {
|
|
582
|
+
return mode;
|
|
583
|
+
},
|
|
584
|
+
get opsApplied() {
|
|
585
|
+
return opsApplied;
|
|
586
|
+
},
|
|
587
|
+
get flushCalls() {
|
|
588
|
+
return flushCalls;
|
|
589
|
+
},
|
|
590
|
+
setMode,
|
|
591
|
+
updateProps: async (next: Record<string, unknown>) => {
|
|
592
|
+
const marshalled = marshalCallbackProps(next, registerCb) as Record<string, unknown>;
|
|
593
|
+
applyOps(await islandCall('updateProps', client.updateProps(instance, marshalled)), 'updateProps');
|
|
594
|
+
if (devtoolsEnabled()) emitDevtools({ type: 'island:props', instance, props: propsPreview(next) });
|
|
595
|
+
},
|
|
596
|
+
flush: async () => {
|
|
597
|
+
applyOps(await islandCall('flush', client.flush(instance)), 'flush');
|
|
598
|
+
},
|
|
599
|
+
destroy: () => {
|
|
600
|
+
if (destroyed) return;
|
|
601
|
+
destroyed = true;
|
|
602
|
+
emitDevtools({ type: 'island:unmount', instance });
|
|
603
|
+
for (const subId of [...slotNodes.keys()]) unmountSlot(subId);
|
|
604
|
+
unsubscribe?.();
|
|
605
|
+
if (pollTimer !== null) clearInterval(pollTimer);
|
|
606
|
+
const remaining = (clientMounts.get(client) ?? 1) - 1;
|
|
607
|
+
clientMounts.set(client, remaining);
|
|
608
|
+
if (givenClient === undefined || remaining <= 0) client.terminate();
|
|
609
|
+
else void islandCall('unmount', client.unmount(instance)).catch(() => {});
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
return handle;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// The unified surface: `mountIsland` delegates here when its `el` is a
|
|
616
|
+
// proxy element. Registration is module-eval so any worker that imports
|
|
617
|
+
// '@atolljs/islands/worker' (every island worker does) makes nested
|
|
618
|
+
// mounting work — the main-thread bundle never loads this module.
|
|
619
|
+
registerSubIslandMounter(mountSubIsland);
|