@atolljs/islands 0.1.6 → 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.
@@ -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);