@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/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
+ }