@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
package/src/island.ts
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-island mounter — the main-thread half of one React tree in one worker.
|
|
3
|
+
*
|
|
4
|
+
* `mountIsland` mounts one island scoped so several can coexist on one page:
|
|
5
|
+
* each island gets its own `connectWorker` client (own pool of ONE worker,
|
|
6
|
+
* own doorbell buffer via `makeDoorbell()`), its own nodes/props/listeners
|
|
7
|
+
* maps (op ids are only unique within a worker — sharing maps between
|
|
8
|
+
* islands would corrupt them), and its own `onEvent` sink for `emit` ops.
|
|
9
|
+
*
|
|
10
|
+
* There is no React on this thread — the driver below just replays ops.
|
|
11
|
+
*
|
|
12
|
+
* poolSize: 1 is REQUIRED per island — the reconciled tree lives in one
|
|
13
|
+
* worker's memory. A real pool can't serve islands today anyway: task routing
|
|
14
|
+
* is least-busy round-robin, so a second worker would receive dispatches for
|
|
15
|
+
* a tree it doesn't hold (sticky routing is future work).
|
|
16
|
+
*/
|
|
17
|
+
import { connectWorker, observe } from '@atolljs/core';
|
|
18
|
+
import type {
|
|
19
|
+
ConnectWorkerConfig,
|
|
20
|
+
SharedSpec,
|
|
21
|
+
WorkerClient,
|
|
22
|
+
WorkerDefinition,
|
|
23
|
+
} from '@atolljs/core';
|
|
24
|
+
import { makeDoorbell, type DoorbellSpec } from './memory';
|
|
25
|
+
import {
|
|
26
|
+
isEventRef,
|
|
27
|
+
type EventPayload,
|
|
28
|
+
type IslandWorkerMethods,
|
|
29
|
+
type Op,
|
|
30
|
+
type WireProps,
|
|
31
|
+
} from './ops';
|
|
32
|
+
|
|
33
|
+
import { marshalCallbackProps, CALLBACK_EVENT } from './callbackProps';
|
|
34
|
+
|
|
35
|
+
export type Mode = 'push' | 'poll';
|
|
36
|
+
|
|
37
|
+
/** The worker definition every `definePolyWorker` call produces. */
|
|
38
|
+
export type IslandWorkerDefinition = WorkerDefinition<DoorbellSpec, IslandWorkerMethods>;
|
|
39
|
+
|
|
40
|
+
/** One island client: one pool, one worker, one doorbell buffer. */
|
|
41
|
+
export type IslandClient = WorkerClient<IslandWorkerDefinition, DoorbellSpec>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Islands are microfrontend containers: each owns ONE worker holding ONE
|
|
45
|
+
* reconciled tree, so pooling is disabled by construction — the type omits
|
|
46
|
+
* `poolSize`/`worker`/`sharedMemory` (all island-internal) and the literal
|
|
47
|
+
* below pins `poolSize: 1` after the spread, so a wider pool can't sneak
|
|
48
|
+
* through a cast either. `worker` is supplied per call — the package can't
|
|
49
|
+
* know where the consumer's worker entry lives — everything else
|
|
50
|
+
* (concurrency, taskTimeout, respawn, lazy…) still passes through.
|
|
51
|
+
*/
|
|
52
|
+
export type IslandWorkerOptions = Omit<
|
|
53
|
+
ConnectWorkerConfig<SharedSpec>,
|
|
54
|
+
'sharedMemory' | 'worker' | 'poolSize'
|
|
55
|
+
>;
|
|
56
|
+
|
|
57
|
+
export interface ConnectIslandWorkerConfig extends IslandWorkerOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Bundler-detectable factory `() => new Worker(new URL('./x.worker.ts',
|
|
60
|
+
* import.meta.url), { type: 'module' })`, or a URL. The worker script must
|
|
61
|
+
* call `definePolyWorker({ apps })` from '@atolljs/islands/worker'.
|
|
62
|
+
*/
|
|
63
|
+
worker: (() => Worker) | URL;
|
|
64
|
+
/**
|
|
65
|
+
* Default true — the client carries a SharedArrayBuffer doorbell for
|
|
66
|
+
* push-mode flush (requires COOP/COEP cross-origin isolation). Pass
|
|
67
|
+
* `false` for a message-only pool: `setMode('push')` falls back to
|
|
68
|
+
* polling and the page needs no special headers.
|
|
69
|
+
*/
|
|
70
|
+
doorbell?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One island client: one pool, one worker, one doorbell buffer. */
|
|
74
|
+
export const connectIslandWorker = <W extends IslandWorkerDefinition = IslandWorkerDefinition>({
|
|
75
|
+
worker,
|
|
76
|
+
doorbell,
|
|
77
|
+
...options
|
|
78
|
+
}: ConnectIslandWorkerConfig): WorkerClient<W, DoorbellSpec> =>
|
|
79
|
+
connectWorker<W, DoorbellSpec>({
|
|
80
|
+
...options,
|
|
81
|
+
sharedMemory: doorbell === false ? undefined : makeDoorbell(),
|
|
82
|
+
worker,
|
|
83
|
+
poolSize: 1,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
interface MountIslandBaseOptions {
|
|
87
|
+
/** Container element the island's ops are applied into. */
|
|
88
|
+
el: HTMLElement;
|
|
89
|
+
/**
|
|
90
|
+
* Registry app name — a key of the `apps` map passed to
|
|
91
|
+
* `definePolyWorker`. Optional against a instance worker
|
|
92
|
+
* (`defineMonoWorker`), which resolves its single app regardless.
|
|
93
|
+
*/
|
|
94
|
+
app?: string;
|
|
95
|
+
props?: Record<string, unknown>;
|
|
96
|
+
/** Island → shell channel: receives every `emit` op the app produces. */
|
|
97
|
+
onEvent?: (name: string, payload: unknown) => void;
|
|
98
|
+
/**
|
|
99
|
+
* Transclusion slots — worker markup renders `<div data-atoll-slot="name">`
|
|
100
|
+
* as a LEAF; when its create op lands, the real element is handed to
|
|
101
|
+
* `slots[name](el)` so the shell can mount main-thread content inside it
|
|
102
|
+
* (a widget, a canvas, even a main-thread React root — real DOM, real
|
|
103
|
+
* events, zero wire). Called again with `null` when the worker removes or
|
|
104
|
+
* renames the element, so the shell can tear down. The element itself
|
|
105
|
+
* stays worker-owned — its box/layout props apply normally.
|
|
106
|
+
*/
|
|
107
|
+
slots?: Record<string, (el: HTMLElement | null) => void>;
|
|
108
|
+
/** Fired after each applied op batch — the shell uses it for stats. */
|
|
109
|
+
onActivity?: () => void;
|
|
110
|
+
/**
|
|
111
|
+
* Perf hook — fired after each op batch is replayed with the batch and the
|
|
112
|
+
* main-thread replay time in ms (performance.now). Pair with client call
|
|
113
|
+
* timing to split worker-vs-main cost for a sub-application.
|
|
114
|
+
*/
|
|
115
|
+
onOps?: (ops: readonly Op[], elapsedMs: number) => void;
|
|
116
|
+
/**
|
|
117
|
+
* Initial flush mode — 'push' (default) subscribes the shared-memory
|
|
118
|
+
* doorbell once the mount handshake lands; 'poll' drains committed ops on
|
|
119
|
+
* a 50ms interval. With the `worker` shorthand, 'poll' also builds the
|
|
120
|
+
* client without a doorbell — no SharedArrayBuffer, so no COOP/COEP
|
|
121
|
+
* cross-origin isolation requirement. Switch later with `handle.setMode`.
|
|
122
|
+
*/
|
|
123
|
+
mode?: Mode;
|
|
124
|
+
/**
|
|
125
|
+
* Milliseconds the mount handshake (mount + whoami) may take before the
|
|
126
|
+
* returned promise rejects — default 15_000, `0` disables. A worker entry
|
|
127
|
+
* that loads but never answers (no definePolyWorker/defineMonoWorker
|
|
128
|
+
* call, an import that hangs) otherwise leaves mount pending forever;
|
|
129
|
+
* hard failures (module error, crash) already reject immediately.
|
|
130
|
+
*/
|
|
131
|
+
mountTimeout?: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The mount needs one worker-side endpoint: either a `client` a
|
|
136
|
+
* `connectIslandWorker` call produced (shareable — several islands can
|
|
137
|
+
* mount into one worker), or a `worker` entry factory/URL this call builds
|
|
138
|
+
* its own client from (the common one-island-per-worker form — the client
|
|
139
|
+
* is island-internal and dies with `destroy()`).
|
|
140
|
+
*/
|
|
141
|
+
export type MountIslandOptions = MountIslandBaseOptions &
|
|
142
|
+
(
|
|
143
|
+
| { client: IslandClient; worker?: undefined }
|
|
144
|
+
| (IslandWorkerOptions & { worker: (() => Worker) | URL; client?: undefined })
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
export interface IslandHandle {
|
|
148
|
+
readonly app: string;
|
|
149
|
+
/** The worker-side instance's random id — proof each island is a distinct worker. */
|
|
150
|
+
readonly pid: string;
|
|
151
|
+
readonly mode: Mode;
|
|
152
|
+
opsApplied: number;
|
|
153
|
+
flushCalls: number;
|
|
154
|
+
/** Re-render the island's root with new serializable props. */
|
|
155
|
+
updateProps(props: Record<string, unknown>): Promise<void>;
|
|
156
|
+
/**
|
|
157
|
+
* Switch how async commits are noticed. The mode given at mount ('push'
|
|
158
|
+
* by default) is started automatically once the handshake lands — call
|
|
159
|
+
* this only to switch modes afterwards. Rebinds of the doorbell contract
|
|
160
|
+
* (in-process harnesses bind every contract on each pool spawn) re-watch
|
|
161
|
+
* transparently.
|
|
162
|
+
*/
|
|
163
|
+
setMode(mode: Mode): void;
|
|
164
|
+
/** Manual flush — drains ops committed outside task calls. */
|
|
165
|
+
flush(): Promise<void>;
|
|
166
|
+
/**
|
|
167
|
+
* Stop transport timers/subscriptions and release the instance. When this
|
|
168
|
+
* island owns the client the worker terminates; when the client is shared
|
|
169
|
+
* (several islands mounted into it) the instance unmounts and the worker
|
|
170
|
+
* lives on for its siblings — it dies with the last island to leave.
|
|
171
|
+
*/
|
|
172
|
+
destroy(): void;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ── DOM driver (per island) ────────────────────────────────────────────── */
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Event types whose real DOM objects always carry numeric pointer fields
|
|
179
|
+
* (MouseEvent and its subclasses — WheelEvent, DragEvent, PointerEvent) —
|
|
180
|
+
* used by listenerFor to normalize absent coords to 0, and by the wheel
|
|
181
|
+
* families for delta fields. `instanceof` checks in listenerFor catch
|
|
182
|
+
* event objects with exotic type names.
|
|
183
|
+
*/
|
|
184
|
+
const POINTER_FAMILY =
|
|
185
|
+
/^(mouse|pointer|drag|drop|wheel|mousewheel|DOMMouseScroll|click|dblclick|auxclick|contextmenu|selectstart|gotpointercapture|lostpointercapture)/;
|
|
186
|
+
const WHEEL_FAMILY = /^(wheel|mousewheel|DOMMouseScroll)$/;
|
|
187
|
+
|
|
188
|
+
/** Build the wire payload for a DOM event, normalizing missing numeric fields
|
|
189
|
+
* so worker-side geometry math never receives undefined. */
|
|
190
|
+
function buildEventPayload(
|
|
191
|
+
e: Event,
|
|
192
|
+
target: EventTarget | null,
|
|
193
|
+
targetId: number | undefined,
|
|
194
|
+
): EventPayload {
|
|
195
|
+
const input = target as HTMLInputElement | null;
|
|
196
|
+
const mouse = e as MouseEvent;
|
|
197
|
+
const wheel = e as WheelEvent;
|
|
198
|
+
const pointer = e as PointerEvent;
|
|
199
|
+
const pointerFam =
|
|
200
|
+
POINTER_FAMILY.test(e.type) ||
|
|
201
|
+
(typeof MouseEvent !== 'undefined' && e instanceof MouseEvent);
|
|
202
|
+
const wheelFam =
|
|
203
|
+
WHEEL_FAMILY.test(e.type) ||
|
|
204
|
+
(typeof WheelEvent !== 'undefined' && e instanceof WheelEvent);
|
|
205
|
+
const num = (v: unknown): number | undefined =>
|
|
206
|
+
typeof v === 'number' ? v : undefined;
|
|
207
|
+
const coord = (v: unknown): number | undefined =>
|
|
208
|
+
num(v) ?? (pointerFam ? 0 : undefined);
|
|
209
|
+
const button = num(mouse.button) ?? (pointerFam ? 0 : undefined);
|
|
210
|
+
return {
|
|
211
|
+
type: e.type,
|
|
212
|
+
value: input && 'value' in input ? input.value : undefined,
|
|
213
|
+
checked: input && 'checked' in input ? input.checked : undefined,
|
|
214
|
+
key: (e as KeyboardEvent).key,
|
|
215
|
+
clientX: coord(mouse.clientX),
|
|
216
|
+
clientY: coord(mouse.clientY),
|
|
217
|
+
screenX: coord(mouse.screenX),
|
|
218
|
+
screenY: coord(mouse.screenY),
|
|
219
|
+
button,
|
|
220
|
+
which:
|
|
221
|
+
typeof mouse.which === 'number' && mouse.which !== 0
|
|
222
|
+
? mouse.which
|
|
223
|
+
: button !== undefined
|
|
224
|
+
? button + 1
|
|
225
|
+
: undefined,
|
|
226
|
+
shiftKey: typeof mouse.shiftKey === 'boolean' ? mouse.shiftKey : undefined,
|
|
227
|
+
ctrlKey: typeof mouse.ctrlKey === 'boolean' ? mouse.ctrlKey : undefined,
|
|
228
|
+
altKey: typeof mouse.altKey === 'boolean' ? mouse.altKey : undefined,
|
|
229
|
+
metaKey: typeof mouse.metaKey === 'boolean' ? mouse.metaKey : undefined,
|
|
230
|
+
deltaX: num(wheel.deltaX) ?? (wheelFam ? 0 : undefined),
|
|
231
|
+
deltaY: num(wheel.deltaY) ?? (wheelFam ? 0 : undefined),
|
|
232
|
+
deltaMode:
|
|
233
|
+
num(wheel.deltaMode) ??
|
|
234
|
+
(wheelFam || typeof wheel.deltaX === 'number' || typeof wheel.deltaY === 'number'
|
|
235
|
+
? 0
|
|
236
|
+
: undefined),
|
|
237
|
+
pointerType: typeof pointer.pointerType === 'string' ? pointer.pointerType : undefined,
|
|
238
|
+
scrollTop: input instanceof HTMLElement ? input.scrollTop : undefined,
|
|
239
|
+
targetId,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Per-island instance counter — see the instance key note in mountIsland. */
|
|
244
|
+
let islandSeq = 0;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Live island count per client — a client is shared infrastructure when
|
|
248
|
+
* several islands mount into it (multi-island-per-worker). destroy() gives
|
|
249
|
+
* its instance back via `unmount`; only the LAST island to leave terminates
|
|
250
|
+
* the worker.
|
|
251
|
+
*/
|
|
252
|
+
const clientMounts = new WeakMap<object, number>();
|
|
253
|
+
|
|
254
|
+
/* ── Prop serializability ─────────────────────────────────────────────────
|
|
255
|
+
* Props cross to the worker inside a postMessage'd task — a function, DOM
|
|
256
|
+
* node, or other uncloneable value dies in the pool's postMessage call as a
|
|
257
|
+
* bare DataCloneError, far from the caller. Pre-flight the props here so
|
|
258
|
+
* the error names the offending key path. Runs before mount/updateProps;
|
|
259
|
+
* the success path costs one structuredClone (cheap at props sizes). */
|
|
260
|
+
|
|
261
|
+
const canClone = (v: unknown): boolean => {
|
|
262
|
+
try {
|
|
263
|
+
structuredClone(v);
|
|
264
|
+
return true;
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> => {
|
|
271
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
272
|
+
const proto: unknown = Object.getPrototypeOf(v);
|
|
273
|
+
return proto === Object.prototype || proto === null;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** Descend a known-uncloneable value to the first uncloneable leaf's path. */
|
|
277
|
+
const uncloneablePath = (value: unknown, path: string): string => {
|
|
278
|
+
const kids: Array<[string, unknown]> = Array.isArray(value)
|
|
279
|
+
? value.map((v, i) => [`[${i}]`, v])
|
|
280
|
+
: isPlainObject(value)
|
|
281
|
+
? Object.entries(value)
|
|
282
|
+
: [];
|
|
283
|
+
for (const [k, v] of kids) {
|
|
284
|
+
if (!canClone(v)) {
|
|
285
|
+
const next = path === '' ? k : k.startsWith('[') ? `${path}${k}` : `${path}.${k}`;
|
|
286
|
+
return uncloneablePath(v, next);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return path;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const assertCloneableProps = (props: Record<string, unknown>, context: string): void => {
|
|
293
|
+
if (canClone(props)) return;
|
|
294
|
+
const path = uncloneablePath(props, '');
|
|
295
|
+
throw new Error(
|
|
296
|
+
`[island] ${context}: prop '${path}' is not structured-cloneable — props cross the worker ` +
|
|
297
|
+
'boundary via postMessage. Pass plain data; behavior crosses through emit/onEvent, ' +
|
|
298
|
+
'callbackProp() for shell functions, slots, or a task method on the worker.',
|
|
299
|
+
);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandle> {
|
|
303
|
+
const {
|
|
304
|
+
el, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
|
|
305
|
+
client: givenClient, worker, app: givenApp, props: givenProps,
|
|
306
|
+
...workerConfig
|
|
307
|
+
} = opts;
|
|
308
|
+
// Shell→worker callables: callbackProp() markers marshal to {__cb:id}
|
|
309
|
+
// wire handles; the island's table keeps the real functions and the
|
|
310
|
+
// driver's emit-case routes worker-side invocations back here.
|
|
311
|
+
const cbTable = new Map<number, (...args: unknown[]) => void>();
|
|
312
|
+
let cbSeq = 0;
|
|
313
|
+
const registerCb = (fn: (...args: unknown[]) => void): number => {
|
|
314
|
+
cbTable.set(++cbSeq, fn);
|
|
315
|
+
return cbSeq;
|
|
316
|
+
};
|
|
317
|
+
const props = marshalCallbackProps(givenProps ?? {}, registerCb) as Record<string, unknown>;
|
|
318
|
+
if (givenClient === undefined && worker === undefined) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
'mountIsland: pass `worker` (a `() => new Worker(...)`/URL entry — this call builds the ' +
|
|
321
|
+
'island-owned client) or `client` (a shared connectIslandWorker — several islands, one worker)',
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
const client: IslandClient =
|
|
325
|
+
givenClient ??
|
|
326
|
+
connectIslandWorker({
|
|
327
|
+
...workerConfig,
|
|
328
|
+
worker,
|
|
329
|
+
// Poll-mode shorthand islands don't need SharedArrayBuffer at all —
|
|
330
|
+
// skip the doorbell contract so the page needs no COOP/COEP headers.
|
|
331
|
+
doorbell: initialMode !== 'poll',
|
|
332
|
+
} as ConnectIslandWorkerConfig);
|
|
333
|
+
assertCloneableProps(props, `mountIsland(${givenApp ?? 'main'})`);
|
|
334
|
+
// 'main' is the unnamed-island name — instance workers (defineMonoWorker)
|
|
335
|
+
// resolve their single app regardless of it.
|
|
336
|
+
const app = givenApp ?? 'main';
|
|
337
|
+
|
|
338
|
+
// Instance key = 'app@N' — the instance suffix keeps each island's instance
|
|
339
|
+
// distinct even when several islands mount the SAME microfrontend — in
|
|
340
|
+
// the same worker (shared client) or across workers. Without it, mounting
|
|
341
|
+
// 'data-table' twice into one worker would remount one shared instance
|
|
342
|
+
// instead of giving each island its own (and they would share a pid).
|
|
343
|
+
const instance = `${app}@${++islandSeq}`;
|
|
344
|
+
clientMounts.set(client, (clientMounts.get(client) ?? 0) + 1);
|
|
345
|
+
|
|
346
|
+
/** instance id → live DOM node. Id 0 is the root container sentinel. */
|
|
347
|
+
const nodes = new Map<number, Node>([[0, el]]);
|
|
348
|
+
/** live DOM node → instance id — fills EventPayload.targetId. */
|
|
349
|
+
const nodeIds = new WeakMap<Node, number>([[el, 0]]);
|
|
350
|
+
/** instance id → last applied prop set (for diffing on `update`). */
|
|
351
|
+
const prevProps = new Map<number, WireProps>();
|
|
352
|
+
/** instance id → event name → attached listener (kept for removal). */
|
|
353
|
+
const nodeListeners = new Map<number, Map<string, EventListener>>();
|
|
354
|
+
/** instance id → slot name, for elements carrying `data-atoll-slot`. */
|
|
355
|
+
const slotNodes = new Map<number, string>();
|
|
356
|
+
|
|
357
|
+
/** Hand a live element to its slot mount callback (or drop it silently). */
|
|
358
|
+
const mountSlot = (node: HTMLElement, id: number, name: string): void => {
|
|
359
|
+
// Always set the attribute — nested-remove detection below finds slot
|
|
360
|
+
// elements inside detached subtrees by querying for it.
|
|
361
|
+
node.setAttribute('data-atoll-slot', name);
|
|
362
|
+
const prev = slotNodes.get(id);
|
|
363
|
+
if (prev !== undefined && prev !== name) opts.slots?.[prev]?.(null);
|
|
364
|
+
slotNodes.set(id, name);
|
|
365
|
+
slots?.[name]?.(node);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const unmountSlot = (id: number): void => {
|
|
369
|
+
const name = slotNodes.get(id);
|
|
370
|
+
if (name !== undefined) {
|
|
371
|
+
slots?.[name]?.(null);
|
|
372
|
+
slotNodes.delete(id);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/** Unmount every slot inside a detached subtree (React removes whole
|
|
377
|
+
* subtrees with ONE `remove` op — a slot deep inside gets no op of its
|
|
378
|
+
* own). Elements created by ops are the only things in the tree, so a
|
|
379
|
+
* DOM query for the marker attribute is exact. */
|
|
380
|
+
// Element-ness is checked structurally (nodeType 1), never via the global
|
|
381
|
+
// `Element` constructor — in in-process embeddings the worker-side instance
|
|
382
|
+
// dispatcher may have pointed that global at ProxyElement, which would
|
|
383
|
+
// make every `instanceof Element` check silently reject real DOM nodes.
|
|
384
|
+
const isElementNode = (node: Node | undefined): node is Element =>
|
|
385
|
+
node !== undefined && node.nodeType === 1;
|
|
386
|
+
|
|
387
|
+
const unmountSlotSubtree = (node: Node): void => {
|
|
388
|
+
if (!isElementNode(node)) return;
|
|
389
|
+
for (const [id, slotEl] of Array.from(slotNodes.entries()).map(([id]) => [id, nodes.get(id)] as const)) {
|
|
390
|
+
if (slotEl === node || node.contains(slotEl as Node)) unmountSlot(id);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
let opsApplied = 0;
|
|
395
|
+
let flushCalls = 0;
|
|
396
|
+
let mode: Mode = initialMode ?? 'push';
|
|
397
|
+
let unsubscribe: (() => void) | null = null;
|
|
398
|
+
let pollTimer: number | null = null;
|
|
399
|
+
let destroyed = false;
|
|
400
|
+
|
|
401
|
+
function listenerFor(handlerId: number): EventListener {
|
|
402
|
+
return (e: Event) => {
|
|
403
|
+
const target = e.target;
|
|
404
|
+
const payload = buildEventPayload(
|
|
405
|
+
e,
|
|
406
|
+
target,
|
|
407
|
+
target !== null ? nodeIds.get(target as Node) : undefined,
|
|
408
|
+
);
|
|
409
|
+
// The whole point: an event = one postMessage round-trip. The worker
|
|
410
|
+
// re-renders, we apply whatever ops come back.
|
|
411
|
+
void client
|
|
412
|
+
.dispatch(handlerId, payload)
|
|
413
|
+
.then(applyOps)
|
|
414
|
+
.catch((err) => console.error(`[island ${instance}] dispatch failed`, err));
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Element-typed, not HTMLElement-typed: createElementNS produces
|
|
419
|
+
// SVGElement/MathMLElement, which share Element's API surface but are NOT
|
|
420
|
+
// HTMLElements — guards and signatures must accept both.
|
|
421
|
+
function setProp(el: Element, id: number, name: string, value: unknown): void {
|
|
422
|
+
if (name === 'data-atoll-slot') {
|
|
423
|
+
mountSlot(el as HTMLElement, id, String(value));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (isEventRef(value)) {
|
|
427
|
+
const eventName = name.slice(2).toLowerCase();
|
|
428
|
+
let table = nodeListeners.get(id);
|
|
429
|
+
if (!table) nodeListeners.set(id, (table = new Map()));
|
|
430
|
+
if (!table.has(eventName)) {
|
|
431
|
+
// Handler ids are stable per (instance, prop) — attach exactly once.
|
|
432
|
+
const listener = listenerFor(value.__evt);
|
|
433
|
+
table.set(eventName, listener);
|
|
434
|
+
el.addEventListener(eventName, listener);
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (name === 'style' && typeof value === 'object' && value !== null) {
|
|
439
|
+
const elStyle = (el as HTMLElement).style as unknown as Record<string, string>;
|
|
440
|
+
const prevStyle = (prevProps.get(id)?.style ?? {}) as Record<string, string>;
|
|
441
|
+
const nextStyle = value as Record<string, string>;
|
|
442
|
+
for (const k of Object.keys(prevStyle)) if (!(k in nextStyle)) elStyle[k] = '';
|
|
443
|
+
for (const [k, v] of Object.entries(nextStyle)) if (prevStyle[k] !== v) elStyle[k] = v;
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (name === 'className') {
|
|
447
|
+
// SVGElement.className is a read-only SVGAnimatedString — assignment
|
|
448
|
+
// throws in strict mode; the class attribute is the portable write.
|
|
449
|
+
if (el instanceof SVGElement) el.setAttribute('class', String(value));
|
|
450
|
+
else el.className = String(value);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (value === true) {
|
|
454
|
+
el.setAttribute(name, '');
|
|
455
|
+
if (name in el) (el as unknown as Record<string, unknown>)[name] = true;
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (value === false) {
|
|
459
|
+
el.removeAttribute(name);
|
|
460
|
+
if (name in el) (el as unknown as Record<string, unknown>)[name] = false;
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// Prefer the DOM property (value, checked, disabled…) when it exists so
|
|
464
|
+
// controlled inputs actually reflect state; fall back to attributes.
|
|
465
|
+
if (name in el) {
|
|
466
|
+
try {
|
|
467
|
+
(el as unknown as Record<string, unknown>)[name] = value;
|
|
468
|
+
return;
|
|
469
|
+
} catch {
|
|
470
|
+
/* read-only property — use the attribute */
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
el.setAttribute(name, String(value));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function removeProp(el: Element, id: number, name: string, oldValue: unknown): void {
|
|
477
|
+
if (name === 'data-atoll-slot') {
|
|
478
|
+
unmountSlot(id);
|
|
479
|
+
el.removeAttribute('data-atoll-slot');
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (isEventRef(oldValue)) {
|
|
483
|
+
const eventName = name.slice(2).toLowerCase();
|
|
484
|
+
const listener = nodeListeners.get(id)?.get(eventName);
|
|
485
|
+
if (listener) {
|
|
486
|
+
el.removeEventListener(eventName, listener);
|
|
487
|
+
nodeListeners.get(id)?.delete(eventName);
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (name === 'className') {
|
|
492
|
+
if (el instanceof SVGElement) el.removeAttribute('class');
|
|
493
|
+
else el.className = '';
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (name === 'style') {
|
|
497
|
+
el.removeAttribute('style');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (name in el && typeof (el as unknown as Record<string, unknown>)[name] === 'boolean') {
|
|
501
|
+
(el as unknown as Record<string, unknown>)[name] = false;
|
|
502
|
+
}
|
|
503
|
+
el.removeAttribute(name);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function applyProps(el: Element, id: number, prev: WireProps, next: WireProps): void {
|
|
507
|
+
for (const name of Object.keys(prev)) {
|
|
508
|
+
if (!(name in next)) removeProp(el, id, name, prev[name]);
|
|
509
|
+
}
|
|
510
|
+
for (const [name, value] of Object.entries(next)) {
|
|
511
|
+
// __evt ids are stable across updates → Object.is equality skips re-attach.
|
|
512
|
+
if (prev[name] !== value || isEventRef(value)) setProp(el, id, name, value);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The `attr` op — the proxy DOM's setAttribute/removeAttribute channel.
|
|
518
|
+
* Values are always strings (or null to remove): the worker-side facade
|
|
519
|
+
* only knows attributes. Reuses setProp's heuristics where they apply —
|
|
520
|
+
* `data-atoll-slot` routes to the slot machinery, `class`/`className` land
|
|
521
|
+
* on the property, other names prefer a DOM property when one exists
|
|
522
|
+
* (`id`, `value`) and fall back to the attribute.
|
|
523
|
+
*/
|
|
524
|
+
function setAttr(node: Element, id: number, name: string, value: string | null): void {
|
|
525
|
+
if (name === 'data-atoll-slot') {
|
|
526
|
+
if (value === null) {
|
|
527
|
+
unmountSlot(id);
|
|
528
|
+
node.removeAttribute('data-atoll-slot');
|
|
529
|
+
} else {
|
|
530
|
+
mountSlot(node as HTMLElement, id, value);
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (value === null) {
|
|
535
|
+
if (name === 'className') node.className = '';
|
|
536
|
+
else if (name === 'style') node.removeAttribute('style');
|
|
537
|
+
else {
|
|
538
|
+
// Live boolean properties (checked) don't follow attribute
|
|
539
|
+
// removal — mirror removeProp's property clear first.
|
|
540
|
+
if (name in node && typeof (node as unknown as Record<string, unknown>)[name] === 'boolean') {
|
|
541
|
+
(node as unknown as Record<string, unknown>)[name] = false;
|
|
542
|
+
}
|
|
543
|
+
node.removeAttribute(name);
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (name === 'class' || name === 'className') {
|
|
548
|
+
if (node instanceof SVGElement) node.setAttribute('class', value);
|
|
549
|
+
else node.className = value;
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (name === 'style') {
|
|
553
|
+
node.setAttribute('style', value);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (name in node) {
|
|
557
|
+
try {
|
|
558
|
+
(node as unknown as Record<string, unknown>)[name] = value;
|
|
559
|
+
return;
|
|
560
|
+
} catch {
|
|
561
|
+
/* read-only property — use the attribute */
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
node.setAttribute(name, value);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* The `style` op — incremental inline-style changes from the proxy DOM's
|
|
569
|
+
* style Proxy. Only changed keys arrive; '' clears a key. The merged set
|
|
570
|
+
* is written back into prevProps so a later React `update` style-diff
|
|
571
|
+
* sees these keys as "prev" and still diffs correctly.
|
|
572
|
+
*/
|
|
573
|
+
function mergeStyle(node: Element, id: number, changes: Record<string, string>): void {
|
|
574
|
+
const elStyle = (node as HTMLElement).style;
|
|
575
|
+
const elStyleMap = elStyle as unknown as Record<string, string>;
|
|
576
|
+
const prev = prevProps.get(id) ?? {};
|
|
577
|
+
const merged = { ...((prev.style ?? {}) as Record<string, string>) };
|
|
578
|
+
for (const [k, v] of Object.entries(changes)) {
|
|
579
|
+
if (k.startsWith('--') || k.includes('-')) {
|
|
580
|
+
// Custom properties and kebab-case keys live outside the camelCase
|
|
581
|
+
// property surface — el.style['font-size'] is a silent no-op on real
|
|
582
|
+
// DOM; setProperty accepts kebab-case, and '' clears either kind.
|
|
583
|
+
elStyle.setProperty(k, v);
|
|
584
|
+
} else if (v.endsWith('!important')) {
|
|
585
|
+
// `style.setProperty(k, v, 'important')` crosses as a suffix — the
|
|
586
|
+
// style op has no separate priority channel.
|
|
587
|
+
elStyle.setProperty(k, v.replace(/\s*!important$/, ''), 'important');
|
|
588
|
+
} else {
|
|
589
|
+
elStyleMap[k] = v;
|
|
590
|
+
}
|
|
591
|
+
merged[k] = v;
|
|
592
|
+
}
|
|
593
|
+
prev.style = merged;
|
|
594
|
+
prevProps.set(id, prev);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/* The island's real DOM lives in the container's own document — NOT the
|
|
598
|
+
* ambient `document` global. In in-process tests (and any same-instance
|
|
599
|
+
* embedding) the worker-side installDomShim swaps globalThis.document for
|
|
600
|
+
* a proxy document; el.ownerDocument is immune. */
|
|
601
|
+
const realDocument = el.ownerDocument ?? document;
|
|
602
|
+
|
|
603
|
+
function applyOp(op: Op): void {
|
|
604
|
+
switch (op.t) {
|
|
605
|
+
case 'create': {
|
|
606
|
+
// ns-carrying ops (svg/math) need createElementNS — plain
|
|
607
|
+
// createElement would produce HTMLUnknownElements for <svg> trees.
|
|
608
|
+
const node = op.ns !== undefined
|
|
609
|
+
? realDocument.createElementNS(op.ns, op.type)
|
|
610
|
+
: realDocument.createElement(op.type);
|
|
611
|
+
nodes.set(op.id, node);
|
|
612
|
+
nodeIds.set(node, op.id);
|
|
613
|
+
for (const [name, value] of Object.entries(op.props)) setProp(node, op.id, name, value);
|
|
614
|
+
// Record prevProps AFTER applying: setProp's style diff reads this map
|
|
615
|
+
// as "what's already on the element" — seeding it first would make the
|
|
616
|
+
// create-time style object diff against itself and drop every key.
|
|
617
|
+
prevProps.set(op.id, op.props);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
case 'text': {
|
|
621
|
+
const node = realDocument.createTextNode(op.text);
|
|
622
|
+
nodes.set(op.id, node);
|
|
623
|
+
nodeIds.set(node, op.id);
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
case 'append': {
|
|
627
|
+
let parent = nodes.get(op.parent);
|
|
628
|
+
const child = nodes.get(op.child);
|
|
629
|
+
if (!parent || !child) {
|
|
630
|
+
console.error(`[island] append skipped: parent=${op.parent}→${String(parent)} child=${op.child}→${String(child)}`);
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
// The DOM spec puts a template's children in its .content fragment —
|
|
634
|
+
// route there explicitly rather than trusting the DOM impl's own
|
|
635
|
+
// redirect (worker-side, content writes emit ops under the
|
|
636
|
+
// template's id).
|
|
637
|
+
if (isElementNode(parent) && parent.tagName === 'TEMPLATE') {
|
|
638
|
+
parent = (parent as HTMLTemplateElement).content;
|
|
639
|
+
}
|
|
640
|
+
const before = op.before !== undefined ? (nodes.get(op.before) ?? null) : null;
|
|
641
|
+
parent.insertBefore(child, before);
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
case 'remove': {
|
|
645
|
+
const child = nodes.get(op.child);
|
|
646
|
+
if (child !== undefined) unmountSlotSubtree(child);
|
|
647
|
+
child?.parentNode?.removeChild(child);
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
case 'update': {
|
|
651
|
+
const node = nodes.get(op.id);
|
|
652
|
+
if (!isElementNode(node)) break;
|
|
653
|
+
const prev = prevProps.get(op.id) ?? {};
|
|
654
|
+
applyProps(node, op.id, prev, op.props);
|
|
655
|
+
prevProps.set(op.id, op.props);
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
case 'utext': {
|
|
659
|
+
const node = nodes.get(op.id);
|
|
660
|
+
if (node) node.textContent = op.text;
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
case 'attr': {
|
|
664
|
+
const node = nodes.get(op.id);
|
|
665
|
+
if (!isElementNode(node)) break;
|
|
666
|
+
setAttr(node, op.id, op.name, op.value);
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
case 'style': {
|
|
670
|
+
const node = nodes.get(op.id);
|
|
671
|
+
if (!isElementNode(node)) break;
|
|
672
|
+
mergeStyle(node, op.id, op.props);
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
case 'listen': {
|
|
676
|
+
const node = nodes.get(op.id);
|
|
677
|
+
if (!isElementNode(node)) break;
|
|
678
|
+
// Keyed by type + handler id so proxy listeners can't collide with
|
|
679
|
+
// the React-prop listener for the same event name, and so unlisten
|
|
680
|
+
// detaches exactly the pair addEventListener created. Id 0 is the
|
|
681
|
+
// island's container — where document/window listeners land, so
|
|
682
|
+
// delegated handlers see every bubbling event inside the island.
|
|
683
|
+
const key = `${op.type}#${op.handler}`;
|
|
684
|
+
let table = nodeListeners.get(op.id);
|
|
685
|
+
if (!table) nodeListeners.set(op.id, (table = new Map()));
|
|
686
|
+
if (!table.has(key)) {
|
|
687
|
+
const listener = listenerFor(op.handler);
|
|
688
|
+
table.set(key, listener);
|
|
689
|
+
node.addEventListener(op.type, listener, op.opts);
|
|
690
|
+
}
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
case 'unlisten': {
|
|
694
|
+
const node = nodes.get(op.id);
|
|
695
|
+
if (!isElementNode(node)) break;
|
|
696
|
+
const key = `${op.type}#${op.handler}`;
|
|
697
|
+
const listener = nodeListeners.get(op.id)?.get(key);
|
|
698
|
+
if (listener) {
|
|
699
|
+
node.removeEventListener(op.type, listener, op.opts?.capture);
|
|
700
|
+
nodeListeners.get(op.id)?.delete(key);
|
|
701
|
+
}
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
case 'clear': {
|
|
705
|
+
// React emits clearContainer inside the SAME commit batch that then
|
|
706
|
+
// appends the new tree (initial mounts and remounts both) — so the
|
|
707
|
+
// node maps must survive this op: the following `append` still needs
|
|
708
|
+
// the ids it just created. Stale ids are never reused; the only cost
|
|
709
|
+
// is a few dead map entries on remount.
|
|
710
|
+
el.replaceChildren();
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
case 'emit': {
|
|
714
|
+
// Reserved name: a worker-side callbackProp invocation — invoke the
|
|
715
|
+
// marshalled shell function instead of surfacing it to onEvent.
|
|
716
|
+
if (op.name === CALLBACK_EVENT) {
|
|
717
|
+
const p = op.payload as { id?: number; args?: unknown[] } | undefined;
|
|
718
|
+
if (p !== null && typeof p === 'object' && typeof p.id === 'number') {
|
|
719
|
+
cbTable.get(p.id)?.(...(p.args ?? []));
|
|
720
|
+
}
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
// Not a DOM mutation — the island→shell channel.
|
|
724
|
+
onEvent?.(op.name, op.payload);
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function applyOps(ops: Op[]): void {
|
|
731
|
+
opsApplied += ops.length;
|
|
732
|
+
onActivity?.();
|
|
733
|
+
const t0 = onOps !== undefined ? performance.now() : 0;
|
|
734
|
+
for (const op of ops) applyOp(op);
|
|
735
|
+
onOps?.(ops, performance.now() - t0);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const doFlush = (): void => {
|
|
739
|
+
if (destroyed) return;
|
|
740
|
+
flushCalls++;
|
|
741
|
+
void client.flush(instance).then(applyOps);
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
/* ── Transport: push (shared-memory doorbell) vs poll ─────────────────── */
|
|
745
|
+
|
|
746
|
+
// The doorbell — opsVersion bumps once per commit in this island's worker;
|
|
747
|
+
// observe() wakes via Atomics.waitAsync, so worker-initiated commits arrive
|
|
748
|
+
// as a push instead of waiting on a poll tick. Subscribed lazily in
|
|
749
|
+
// setMode() — see the note on IslandHandle.setMode about bind ordering.
|
|
750
|
+
const doorbell = client.sharedMemory ? observe(client.sharedMemory, 'opsVersion') : null;
|
|
751
|
+
|
|
752
|
+
function setMode(next: Mode): void {
|
|
753
|
+
mode = next;
|
|
754
|
+
unsubscribe?.();
|
|
755
|
+
unsubscribe = null;
|
|
756
|
+
|
|
757
|
+
if (pollTimer !== null) {
|
|
758
|
+
clearInterval(pollTimer);
|
|
759
|
+
pollTimer = null;
|
|
760
|
+
}
|
|
761
|
+
if (mode === 'push' && doorbell !== null) unsubscribe = doorbell.subscribe(doFlush);
|
|
762
|
+
else pollTimer = window.setInterval(doFlush, 50);
|
|
763
|
+
onActivity?.();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/* ── Mount ────────────────────────────────────────────────────────────── */
|
|
767
|
+
|
|
768
|
+
// Bound the handshake: a worker entry that loads but never answers (no
|
|
769
|
+
// define*Worker call, a hung import) would otherwise pend forever — hard
|
|
770
|
+
// failures already reject via the pool's 'error' handling.
|
|
771
|
+
const mountMs = mountTimeout ?? 15_000;
|
|
772
|
+
const raced = <T>(p: Promise<T>): Promise<T> => {
|
|
773
|
+
if (mountMs <= 0) return p;
|
|
774
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
775
|
+
return Promise.race([
|
|
776
|
+
p,
|
|
777
|
+
new Promise<never>((_, reject) => {
|
|
778
|
+
timer = setTimeout(
|
|
779
|
+
() =>
|
|
780
|
+
reject(
|
|
781
|
+
new Error(
|
|
782
|
+
`mountIsland(${app}): the worker never answered within ${mountMs}ms — ` +
|
|
783
|
+
'check that the entry module loads (bundler path, uncaught import errors) ' +
|
|
784
|
+
'and calls definePolyWorker/defineMonoWorker. Pass { mountTimeout: 0 } to disable.',
|
|
785
|
+
),
|
|
786
|
+
),
|
|
787
|
+
mountMs,
|
|
788
|
+
);
|
|
789
|
+
}),
|
|
790
|
+
]).finally(() => clearTimeout(timer));
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
let pid: string;
|
|
794
|
+
try {
|
|
795
|
+
applyOps(await raced(client.mount(instance, props)));
|
|
796
|
+
pid = await raced(client.whoami(instance));
|
|
797
|
+
} catch (err) {
|
|
798
|
+
// The instance may exist worker-side with ops we never applied — release it.
|
|
799
|
+
// An island-owned client also terminates (stops a crash→respawn loop on
|
|
800
|
+
// a permanently-broken entry); a shared client just drops the instance.
|
|
801
|
+
clientMounts.set(client, (clientMounts.get(client) ?? 1) - 1);
|
|
802
|
+
if (givenClient === undefined) client.terminate();
|
|
803
|
+
else void client.unmount(instance).catch(() => {});
|
|
804
|
+
throw err;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Start the transport now that the handshake landed — 'push' subscribes
|
|
808
|
+
// the doorbell, 'poll' starts the interval. This used to be a manual
|
|
809
|
+
// post-mount ritual; subscriptions now survive contract rebinds.
|
|
810
|
+
setMode(mode);
|
|
811
|
+
|
|
812
|
+
/* ── Pushed-size channel ─────────────────────────────────────────────────
|
|
813
|
+
*
|
|
814
|
+
* The proxy DOM's only geometry is the box THIS element renders — so the
|
|
815
|
+
* driver measures it and pushes {w,h} into the instance via setSize: once
|
|
816
|
+
* immediately (the app's first size-aware work happens then — mount()
|
|
817
|
+
* itself ran before any size existed), and on every ResizeObserver tick
|
|
818
|
+
* throttled to ~100ms (trailing, plus a leading call when the window is
|
|
819
|
+
* quiet). Pushed ops from onResize handlers ride back in the task's
|
|
820
|
+
* return value like dispatch() results.
|
|
821
|
+
*/
|
|
822
|
+
let sizeTimer: number | null = null;
|
|
823
|
+
let sizeLastPush = -Infinity;
|
|
824
|
+
let lastW = -1;
|
|
825
|
+
let lastH = -1;
|
|
826
|
+
const pushSize = (): void => {
|
|
827
|
+
const w = el.clientWidth;
|
|
828
|
+
const h = el.clientHeight;
|
|
829
|
+
if (w === lastW && h === lastH) return;
|
|
830
|
+
lastW = w;
|
|
831
|
+
lastH = h;
|
|
832
|
+
void client.setSize(instance, w, h).then(applyOps);
|
|
833
|
+
};
|
|
834
|
+
const resizeObserver =
|
|
835
|
+
typeof ResizeObserver === 'function'
|
|
836
|
+
? new ResizeObserver(() => {
|
|
837
|
+
const now = Date.now();
|
|
838
|
+
const elapsed = now - sizeLastPush;
|
|
839
|
+
if (elapsed >= 100) {
|
|
840
|
+
sizeLastPush = now;
|
|
841
|
+
pushSize();
|
|
842
|
+
} else if (sizeTimer === null) {
|
|
843
|
+
sizeTimer = window.setTimeout(() => {
|
|
844
|
+
sizeTimer = null;
|
|
845
|
+
sizeLastPush = Date.now();
|
|
846
|
+
pushSize();
|
|
847
|
+
}, 100 - elapsed);
|
|
848
|
+
}
|
|
849
|
+
})
|
|
850
|
+
: null;
|
|
851
|
+
resizeObserver?.observe(el);
|
|
852
|
+
sizeLastPush = Date.now();
|
|
853
|
+
pushSize();
|
|
854
|
+
|
|
855
|
+
const handle: IslandHandle = {
|
|
856
|
+
app,
|
|
857
|
+
pid,
|
|
858
|
+
get mode() {
|
|
859
|
+
return mode;
|
|
860
|
+
},
|
|
861
|
+
get opsApplied() {
|
|
862
|
+
return opsApplied;
|
|
863
|
+
},
|
|
864
|
+
get flushCalls() {
|
|
865
|
+
return flushCalls;
|
|
866
|
+
},
|
|
867
|
+
setMode,
|
|
868
|
+
updateProps: async (next: Record<string, unknown>) => {
|
|
869
|
+
const marshalled = marshalCallbackProps(next, registerCb) as Record<string, unknown>;
|
|
870
|
+
assertCloneableProps(marshalled, `updateProps(${app})`);
|
|
871
|
+
applyOps(await client.updateProps(instance, marshalled));
|
|
872
|
+
},
|
|
873
|
+
flush: async () => {
|
|
874
|
+
applyOps(await client.flush(instance));
|
|
875
|
+
},
|
|
876
|
+
destroy: () => {
|
|
877
|
+
destroyed = true;
|
|
878
|
+
unsubscribe?.();
|
|
879
|
+
resizeObserver?.disconnect();
|
|
880
|
+
if (pollTimer !== null) clearInterval(pollTimer);
|
|
881
|
+
if (sizeTimer !== null) clearTimeout(sizeTimer);
|
|
882
|
+
const remaining = (clientMounts.get(client) ?? 1) - 1;
|
|
883
|
+
clientMounts.set(client, remaining);
|
|
884
|
+
if (remaining > 0) {
|
|
885
|
+
// The worker still serves sibling mounts — hand this one back. The
|
|
886
|
+
// detach ops are irrelevant if our element is already gone, so the
|
|
887
|
+
// return batch is dropped. Best-effort: the instance map and its
|
|
888
|
+
// handlers release regardless.
|
|
889
|
+
void client.unmount(instance).catch(() => {});
|
|
890
|
+
} else {
|
|
891
|
+
// Last island — still run the instance's teardown first so the app's
|
|
892
|
+
// dispose hook cancels deferred work (library timers, animation
|
|
893
|
+
// loops) and the proxy doc unwinds. In a real worker terminate may
|
|
894
|
+
// kill the thread before unmount lands — equally fine, process
|
|
895
|
+
// death is teardown — but in-process workers need this or the
|
|
896
|
+
// instance zombies on and its pending callbacks mutate foreign docs.
|
|
897
|
+
void client
|
|
898
|
+
.unmount(instance)
|
|
899
|
+
.catch(() => {})
|
|
900
|
+
.finally(() => client.terminate());
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
};
|
|
904
|
+
return handle;
|
|
905
|
+
}
|