@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.
@@ -0,0 +1,666 @@
1
+ /**
2
+ * Two entry points, two topologies:
3
+ *
4
+ * // render.worker.ts — a REGISTRY worker: one script, many apps
5
+ * import { definePolyWorker } from '@atolljs/islands/worker';
6
+ * export const renderWorker = definePolyWorker({
7
+ * apps: { controls: ControlsApp, table: TableApp },
8
+ * });
9
+ *
10
+ * // charts.worker.ts — a MONO worker: one script, one app (1:1)
11
+ * export const chartsWorker = defineMonoWorker(ChartsApp);
12
+ *
13
+ * `definePolyWorker` serves a registry — every island's worker runs the
14
+ * same script and `mount(instance, props)` picks a component out of `apps`.
15
+ * `defineMonoWorker` serves exactly one app: the shell mounts it without
16
+ * naming a registry key (or with any name — a single-registered-app worker
17
+ * resolves its sole app regardless), and the worker's bundle carries only
18
+ * that app's dependencies.
19
+ *
20
+ * APP REGISTRY is module-level: in a real worker each entry file loads its
21
+ * own module graph (a instance worker sees exactly its app); under the
22
+ * in-process test harness several worker entries share one graph, so
23
+ * registration is a UNION and mounts still resolve — same semantics both
24
+ * worlds.
25
+ *
26
+ * INSTANCE KEYS are `app` or `app@instance` — the part before the last '@'
27
+ * names the registry app (or 'main' for unnamed instance-worker mounts), the
28
+ * rest distinguishes instances so the same app can mount more than once —
29
+ * INCLUDING into one shared worker when two islands share a client
30
+ * (multi-island-per-worker: one worker, two mounts, two reconcilers).
31
+ * MountIsland mints a fresh key per island. Wire signatures carry the instance key (`updateProps(instance,
32
+ * props)`, `flush(instance)`, `whoami(instance)`) so a call routes to its instance —
33
+ * the main-thread mountIsland helper binds it, so shell code just calls
34
+ * `island.updateProps(props)`.
35
+ *
36
+ * IMPERATIVE INSTANCES: a registry entry can be `{ imperative: (doc, props) }`
37
+ * instead of a component. Those mounts hold no reconciler at all: mount
38
+ * hands the app a instance-scoped proxy DOM and its mutations emit ops
39
+ * directly (updateProps = clear + rebuild, dispatch = runInInstance + drain,
40
+ * flush = drain only). See worker/proxyDom.ts.
41
+ *
42
+ * RENDERED INSTANCES: `{ mount: (ctx) => handle | void }` is the same shape
43
+ * for non-React framework renderers — mount(ctx) renders into the instance's
44
+ * proxy document and returns a handle whose `update(props)` takes over
45
+ * updateProps (fine-grained patching instead of rebuild) and whose
46
+ * `dispose()` runs before the document dies.
47
+ *
48
+ * Ops still ride back through the pool's ordinary postMessage channel — the
49
+ * sharedMemory contract here is only the doorbell (see memory.ts): a commit
50
+ * counter the main thread observe()s to trigger flush() as a push. Drop it
51
+ * entirely and the islands still work on the 50ms poll — that's the
52
+ * message-only mode.
53
+ */
54
+
55
+ import type { ReactElement } from 'react';
56
+ import type { ReactInstance } from './reactInstance';
57
+ import { defineWorker } from '@atolljs/core';
58
+ import type { SharedMemory, WorkerDefinition } from '@atolljs/core';
59
+ import { islandAppNameOf } from '../app';
60
+ import { renderMemory, type DoorbellSpec } from '../memory';
61
+ import { CALLBACK_EVENT, unmarshalCallbackProps } from '../callbackProps';
62
+ import {
63
+ createProxyDocument,
64
+ installInstanceDispatcher,
65
+ docForInstance,
66
+ type InternalDocument,
67
+ type ProxyDocument,
68
+ } from './proxyDom';
69
+ import {
70
+ bumpOpsVersion,
71
+ getHandler,
72
+ instances,
73
+ pushOp,
74
+ runInInstance,
75
+ setActiveInstance,
76
+ setDoorbellContract,
77
+ setInstanceSize,
78
+ takeOps,
79
+ } from './instance';
80
+ import type { EventPayload, IslandWorkerMethods, Op } from '../ops';
81
+
82
+ /**
83
+ * Registry value shapes:
84
+ * - a React component function — reconciled into the instance's own root.
85
+ * (`props: any` so plain `function App({ x }: Props)` components register
86
+ * without casts — props are serialized across the wire anyway.)
87
+ * - `{ imperative: (doc, props) => void }` — NO React at all. mount() hands
88
+ * it a proxy DOM scoped to the instance; its mutations emit ops directly.
89
+ */
90
+ export type ReactIslandApp = (props: any) => ReactElement;
91
+ export interface ImperativeIslandApp {
92
+ imperative: (doc: ProxyDocument, props: Record<string, unknown>) => void;
93
+ /**
94
+ * Optional teardown — runs inside the instance's scope BEFORE its proxy
95
+ * document is disposed (unmount, remount, updateProps rebuild). Cancel
96
+ * library timers/animation loops/listeners here (e.g. `map.remove()`) so
97
+ * deferred work can't mutate a dead instance or a foreign ambient document
98
+ * after teardown.
99
+ */
100
+ dispose?: (doc: ProxyDocument) => void;
101
+ }
102
+
103
+ /**
104
+ * What a non-React framework renderer receives at mount: the instance's wire
105
+ * key (scopes emit() and instance-less ops), a fresh proxy document whose
106
+ * mutations emit ops, and the serialized props the shell mounted with.
107
+ */
108
+ export interface RenderContext {
109
+ instance: string;
110
+ doc: ProxyDocument;
111
+ props: Record<string, unknown>;
112
+ }
113
+ export interface RenderedHandle {
114
+ /**
115
+ * Fine-grained prop update — the renderer patches its live tree. When
116
+ * omitted, updateProps falls back to imperative semantics: dispose +
117
+ * clear + re-mount on a fresh document.
118
+ */
119
+ update?(props: Record<string, unknown>): void;
120
+ /**
121
+ * App teardown — runs inside the instance's scope BEFORE its proxy document
122
+ * is disposed (unmount, remount, updateProps rebuild). Same rules as
123
+ * ImperativeIslandApp.dispose: cancel framework roots/effects here.
124
+ */
125
+ dispose?(): void;
126
+ }
127
+ /**
128
+ * A renderer-backed app: a non-React framework renderer (Vue, Svelte,
129
+ * Solid, Angular) whose `mount` renders component output into the instance's
130
+ * proxy document — every proxy mutation already serializes to ops. Package
131
+ * adapters (e.g. @atolljs/vue-island/worker) wrap a component into
132
+ * this shape so it can sit beside React and imperative apps in the same
133
+ * `apps` registry.
134
+ */
135
+ export interface RenderedIslandApp {
136
+ mount(ctx: RenderContext): RenderedHandle | void;
137
+ }
138
+ /** The apps an island can mount — keyed by the name the shell passes to mount(). */
139
+ export type IslandApp = ReactIslandApp | ImperativeIslandApp | RenderedIslandApp;
140
+
141
+ export interface PolyWorkerRegistry {
142
+ /** Name → island app registry. The shell's `mountIsland({ app: name })`
143
+ * picks one per island. */
144
+ apps: Record<string, IslandApp>;
145
+ /**
146
+ * The doorbell contract the worker exposes — defaults to the package's
147
+ * `renderMemory`. Rarely needed: pass a different doorbell-SPEC instance
148
+ * if your worker module already defines one (the main side always uses
149
+ * `makeDoorbell()`, so the spec must match: `opsVersion` number field).
150
+ */
151
+ sharedMemory?: SharedMemory<DoorbellSpec>;
152
+ }
153
+
154
+ const isImperative = (app: IslandApp | undefined): app is ImperativeIslandApp =>
155
+ typeof app === 'object' && app !== null && 'imperative' in app;
156
+
157
+ const isRendered = (app: IslandApp | undefined): app is RenderedIslandApp =>
158
+ typeof app === 'object' && app !== null && 'mount' in app;
159
+
160
+ /**
161
+ * Wrap a `{__cb:id}` wire handle into the callable the app receives: pushing
162
+ * a reserved `emit` op onto THIS instance's queue (the driver's emit-case
163
+ * dispatches it to the marshalled shell function) and ringing the doorbell
164
+ * so out-of-task invocations — timers, continuations — still flush.
165
+ * Fire-and-forget: there is no channel for a shell return value.
166
+ */
167
+ const callbackFactory =
168
+ (instance: string) =>
169
+ (id: number) =>
170
+ (...args: unknown[]): void => {
171
+ pushOp(instance, { t: 'emit', name: CALLBACK_EVENT, payload: { id, args } });
172
+ bumpOpsVersion();
173
+ };
174
+
175
+ interface InstanceBase {
176
+ /** The wire key — 'app' or 'app@instance'; op queues route by this. */
177
+ key: string;
178
+ /** The registry name — which component this instance renders. */
179
+ app: string;
180
+ /** Per-instance random id — shown in the island's badge as proof it's a
181
+ * distinct render instance (in production: a distinct worker). Stable across
182
+ * remounts of the same instance key. */
183
+ pid: string;
184
+ }
185
+
186
+ interface ImperativeInstance extends InstanceBase {
187
+ rendered?: undefined;
188
+ imperative: {
189
+ build: ImperativeIslandApp['imperative'];
190
+ /** Optional app teardown — run before the instance's doc is disposed. */
191
+ dispose: ImperativeIslandApp['dispose'];
192
+ /** The instance's proxy document — replaced on each rebuild. */
193
+ doc: ProxyDocument;
194
+ props: Record<string, unknown>;
195
+ };
196
+ }
197
+
198
+ interface RenderedInstance extends InstanceBase {
199
+ imperative?: undefined;
200
+ rendered: {
201
+ app: RenderedIslandApp;
202
+ /** The mount-returned handle — drives fine-grained updates/teardown. */
203
+ handle: RenderedHandle | void;
204
+ /** The instance's proxy document — replaced on each rebuild. */
205
+ doc: ProxyDocument;
206
+ props: Record<string, unknown>;
207
+ };
208
+ }
209
+
210
+ /** A mounted instance — one per island worker in production. */
211
+ export type Instance = ReactInstance | ImperativeInstance | RenderedInstance;
212
+
213
+ const isImperativeInstance = (r: Instance): r is ImperativeInstance => r.imperative !== undefined;
214
+ const isRenderedInstance = (r: Instance): r is RenderedInstance => r.rendered !== undefined;
215
+
216
+ /** instance key → mounted instance. Production workers hold one entry per island
217
+ * mounted into them — more than one only when islands share a client. */
218
+ const mounts = new Map<string, Instance>();
219
+
220
+ /**
221
+ * Module-level app registry — one module graph = one registry (see the
222
+ * header note). definePolyWorker registers its whole `apps` map;
223
+ * defineMonoWorker registers its single app.
224
+ */
225
+ const APP_REGISTRY = new Map<string, IslandApp>();
226
+
227
+ const registerSingleApp = (name: string, app: IslandApp): void => {
228
+ APP_REGISTRY.set(name, app);
229
+ };
230
+
231
+ /**
232
+ * Resolve a mount's registry name → app. A single-entry registry is
233
+ * name-blind: a instance worker mounts its one app whatever instance key arrives
234
+ * ('main@1' from an un-named <Island/>, 'charts@2' from a stamped one), so
235
+ * `app` is genuinely optional on the shell side for 1:1 workers.
236
+ */
237
+ const resolveApp = (name: string): IslandApp | undefined =>
238
+ APP_REGISTRY.get(name) ??
239
+ (APP_REGISTRY.size === 1 ? APP_REGISTRY.values().next().value : undefined);
240
+
241
+ const newPid = (): string => `w-${Math.random().toString(36).slice(2, 8)}`;
242
+
243
+ /**
244
+ * react/react-reconciler arrive here — dynamically, so a worker bundle whose
245
+ * registry holds only Vue/Svelte/Solid/Angular/imperative apps never pays
246
+ * for the reconciler. Cached after the first React mount.
247
+ */
248
+ let reactRuntime: Promise<typeof import('./reactInstance')> | undefined;
249
+ const loadReactRuntime = (): Promise<typeof import('./reactInstance')> =>
250
+ (reactRuntime ??= import('./reactInstance'));
251
+
252
+ /** 'controls' or 'data-table@7' → 'data-table' — registry name part of a instance key. */
253
+ const appNameOf = (instance: string): string => {
254
+ const at = instance.lastIndexOf('@');
255
+ return at === -1 ? instance : instance.slice(0, at);
256
+ };
257
+
258
+ /**
259
+ * The worker-side half of an island — registers mount / updateProps /
260
+ * dispatch / flush / whoami on the worker's task surface and wires the
261
+ * doorbell contract. Shared by both entry points; they differ only in what
262
+ * they register.
263
+ */
264
+ function createIslandRuntime(
265
+ sharedMemory: SharedMemory<DoorbellSpec> = renderMemory,
266
+ ): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
267
+ // Point bumpOpsVersion at the declared contract (it's the same object as
268
+ // renderMemory unless a custom doorbell instance was passed).
269
+ setDoorbellContract(sharedMemory);
270
+ // Give every instance DOM globals — `document`/`window`/`Element` resolve to
271
+ // the active instance's proxy document. Libraries a React app pulls in
272
+ // (recharts, d3-ish helpers) can then read them without the app ever
273
+ // installing a shim; imperative apps get the same globals via
274
+ // installDomShim, which builds on this.
275
+ installInstanceDispatcher();
276
+
277
+ // Legacy root (tag 0) — no concurrent features. Container creation is
278
+ // per-instance, not module-level, so a second mount() can't collide with the
279
+ // first instance's tree. Imperative mounts skip the reconciler entirely —
280
+ // their "host environment" is the proxy DOM, and build() emits ops itself.
281
+ function createInstance(key: string, pid: string): Instance {
282
+ const app = resolveApp(appNameOf(key));
283
+ if (isRendered(app)) {
284
+ return {
285
+ key,
286
+ app: appNameOf(key),
287
+ pid,
288
+ rendered: { app, handle: undefined, doc: createProxyDocument(key), props: {} },
289
+ };
290
+ }
291
+ if (isImperative(app)) {
292
+ return {
293
+ key,
294
+ app: appNameOf(key),
295
+ pid,
296
+ imperative: {
297
+ build: app.imperative,
298
+ dispose: app.dispose,
299
+ doc: createProxyDocument(key),
300
+ props: {},
301
+ },
302
+ };
303
+ }
304
+ throw new Error(
305
+ `createInstance: "${appNameOf(key)}" resolved to a React app — React mounts are created by reactInstance (lazy import), this path is unreachable`,
306
+ );
307
+ }
308
+
309
+ /**
310
+ * Commit synchronously around `fn` and return the ops it produced, scoped
311
+ * to `instance`'s queue.
312
+ *
313
+ * In 0.34, `updateContainer`/setState only *schedule* work — the actual
314
+ * render+commit happens when the root scheduler task runs (a macrotask via
315
+ * the `scheduler` package). `flushSyncFromReconciler` pins the update
316
+ * priority to the discrete/sync lane for the duration of `fn` and flushes
317
+ * pending sync work in its `finally`, so by the time it returns the
318
+ * mutation hooks have run and the instance's op queue is full.
319
+ */
320
+ function syncCommit(instance: ReactInstance, fn: () => void): Op[] {
321
+ const prev = setActiveInstance(instance.key);
322
+ try {
323
+ instance.reconciler.flushSyncFromReconciler(fn);
324
+ instance.reconciler.flushSyncWork();
325
+ return takeOps(instance.key);
326
+ } finally {
327
+ setActiveInstance(prev);
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Rebuild an imperative instance — the simplest honest updateProps/remount
333
+ * semantics for code with no reconciler: dispose the old document (its
334
+ * handler ids die with it — and the global DOM shim it installed is
335
+ * unwound), emit `clear` so the driver empties the island root, then
336
+ * re-run build() on a FRESH proxy document whose shadow tree starts
337
+ * empty like the real one. All inside the instance's active scope so emit()
338
+ * and instance-less ops route correctly.
339
+ */
340
+ function rebuildImperative(instance: ImperativeInstance, props: Record<string, unknown>): Op[] {
341
+ return runInInstance(instance.key, () => {
342
+ const imp = instance.imperative;
343
+ imp.dispose?.(imp.doc);
344
+ imp.doc.dispose();
345
+ pushOp(instance.key, { t: 'clear' });
346
+ imp.doc = createProxyDocument(instance.key);
347
+ imp.props = props;
348
+ imp.build(imp.doc, props);
349
+ return takeOps(instance.key);
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Rebuild a rendered instance — dispose the framework's mount handle, drop
355
+ * the proxy document, `clear`, then re-mount on a fresh document. Used
356
+ * for remounts and for updateProps when the handle exposes no `update`.
357
+ */
358
+ function rebuildRendered(instance: RenderedInstance, props: Record<string, unknown>): Op[] {
359
+ return runInInstance(instance.key, () => {
360
+ const r = instance.rendered;
361
+ r.handle?.dispose?.();
362
+ r.doc.dispose();
363
+ pushOp(instance.key, { t: 'clear' });
364
+ r.doc = createProxyDocument(instance.key);
365
+ r.props = props;
366
+ r.handle = r.app.mount({ instance: instance.key, doc: r.doc, props });
367
+ return takeOps(instance.key);
368
+ });
369
+ }
370
+
371
+ return defineWorker({
372
+ sharedMemory,
373
+ methods: {
374
+ /**
375
+ * Mount apps[appNameOf(instance)] into its own root; returns the
376
+ * initial op batch. The instance key may carry an instance suffix
377
+ * ('data-table@3') so the same app can mount multiple times.
378
+ *
379
+ * Mounting a instance key that is already mounted is a REMOUNT: the old
380
+ * tree is unmounted first (a `clear` op + GC of its instance records),
381
+ * then a fresh container renders the new tree — the returned batch
382
+ * replays cleanly onto an emptied root. The pid is kept across remounts.
383
+ */
384
+ async mount(instance: string, props: Record<string, unknown> = {}): Promise<Op[]> {
385
+ // {__cb:id} handles become callables scoped to THIS instance — invoking
386
+ // one emits a callback-prop op the driver routes to the shell's
387
+ // marshalled function (fire-and-forget, doorbell-rung for out-of-
388
+ // task callers like timers/promise continuations).
389
+ props = unmarshalCallbackProps(props, callbackFactory(instance)) as Record<string, unknown>;
390
+ const App = resolveApp(appNameOf(instance));
391
+ if (App === undefined) {
392
+ throw new Error(
393
+ `mount: unknown app "${instance}" — registry has: ${[...APP_REGISTRY.keys()].join(', ')}`,
394
+ );
395
+ }
396
+
397
+ let mounted = mounts.get(instance);
398
+ // Imperative/rendered remount — same clear+rebuild semantics as
399
+ // updateProps; the instance (and pid) survives.
400
+ if (mounted !== undefined && isImperativeInstance(mounted)) {
401
+ return rebuildImperative(mounted, props);
402
+ }
403
+ if (mounted !== undefined && isRenderedInstance(mounted)) {
404
+ return rebuildRendered(mounted, props);
405
+ }
406
+
407
+ let ops: Op[] = [];
408
+ if (!isRendered(App) && !isImperative(App)) {
409
+ // React app — the reconciler arrives via a lazy import so
410
+ // non-React worker bundles never pay for it.
411
+ const rt = await loadReactRuntime();
412
+ let reactInstance: ReactInstance;
413
+ if (mounted !== undefined) {
414
+ // Remount — unmount the existing tree so React detaches its
415
+ // instances, then rebuild on a fresh container. The pid is kept.
416
+ const old = mounted as ReactInstance;
417
+ ops = syncCommit(old, () => old.unmountTree());
418
+ reactInstance = rt.createReactInstance(instance, appNameOf(instance), old.pid);
419
+ } else {
420
+ reactInstance = rt.createReactInstance(instance, appNameOf(instance), newPid());
421
+ }
422
+ mounts.set(instance, reactInstance);
423
+ return ops.concat(
424
+ syncCommit(reactInstance, () => reactInstance.render(App, props)),
425
+ );
426
+ }
427
+
428
+ // Imperative/rendered first mount. (A mounted React instance being
429
+ // replaced by a non-React app unmounts through syncCommit first.)
430
+ if (mounted !== undefined) {
431
+ const old = mounted as ReactInstance;
432
+ ops = syncCommit(old, () => old.unmountTree());
433
+ mounted = createInstance(instance, old.pid);
434
+ } else {
435
+ mounted = createInstance(instance, newPid());
436
+ }
437
+ mounts.set(instance, mounted);
438
+
439
+ if (isImperativeInstance(mounted)) {
440
+ // First mount of an imperative instance — run build() in the instance's
441
+ // scope and drain the ops its proxy-DOM mutations emitted.
442
+ const imp = mounted.imperative;
443
+ imp.props = props;
444
+ return ops.concat(
445
+ runInInstance(instance, () => {
446
+ imp.build(imp.doc, props);
447
+ return takeOps(instance);
448
+ }),
449
+ );
450
+ }
451
+
452
+ // First mount of a rendered instance — mount() in the instance's scope;
453
+ // the framework's proxy-DOM mutations emit the initial op batch.
454
+ const r = (mounted as RenderedInstance).rendered;
455
+ r.props = props;
456
+ return ops.concat(
457
+ runInInstance(instance, () => {
458
+ r.handle = r.app.mount({ instance, doc: r.doc, props });
459
+ return takeOps(instance);
460
+ }),
461
+ );
462
+ },
463
+
464
+ /**
465
+ * Re-render the instance's root with new props — the shell→island channel.
466
+ * `updateProps` is how the shell mediates between islands (controls
467
+ * emits filterChanged → shell → table.updateProps({filter})).
468
+ */
469
+ updateProps(instance: string, props: Record<string, unknown>): Op[] {
470
+ props = unmarshalCallbackProps(props, callbackFactory(instance)) as Record<string, unknown>;
471
+ const mounted = mounts.get(instance);
472
+ if (mounted === undefined) {
473
+ throw new Error(`updateProps: "${instance}" is not mounted in this worker — mount() first`);
474
+ }
475
+ // Imperative mounts have no diffing — updateProps REBUILDS: clear the
476
+ // root and re-run build(props) on a fresh proxy document. Documented
477
+ // as the honest semantics; fine for widgets, not for huge trees.
478
+ if (isImperativeInstance(mounted)) return rebuildImperative(mounted, props);
479
+ // Rendered mounts prefer their own fine-grained update; absent a
480
+ // handle.update they fall back to the same rebuild.
481
+ if (isRenderedInstance(mounted)) {
482
+ const r = mounted.rendered;
483
+ if (r.handle?.update === undefined) return rebuildRendered(mounted, props);
484
+ return runInInstance(instance, () => {
485
+ r.handle!.update!(props);
486
+ r.props = props;
487
+ return takeOps(instance);
488
+ });
489
+ }
490
+ const App = resolveApp(mounted.app) as (props: Record<string, unknown>) => ReactElement;
491
+ return syncCommit(mounted, () => mounted.render(App, props));
492
+ },
493
+
494
+ /**
495
+ * Run the prop function the main thread identified by handlerId —
496
+ * `__evt` refs are handles into the worker's handler table. The entry
497
+ * records which instance registered it, so the re-render's ops land on the
498
+ * right island's queue.
499
+ */
500
+ dispatch(handlerId: number, payload: EventPayload): Op[] {
501
+ const entry = getHandler(handlerId);
502
+ if (entry === undefined) return [];
503
+ const instance = mounts.get(entry.instance);
504
+ if (instance === undefined) return []; // stale handler — its tree was remounted
505
+ // Give handlers real event-object semantics: `target` is the proxy
506
+ // node for the wire's targetId (when it maps to an op-created node),
507
+ // `currentTarget` the element this handler was attached to. Libraries
508
+ // that read geometry off them (recharts' getRelativeCoordinate) get
509
+ // the proxy's honest zeros instead of crashing on undefined.
510
+ const p = payload as EventPayload & { target?: unknown; currentTarget?: unknown };
511
+ if (p.target === undefined || p.currentTarget === undefined) {
512
+ const doc = docForInstance(instance.key);
513
+ if (p.target === undefined && typeof p.targetId === 'number') {
514
+ const t = instances.get(p.targetId);
515
+ if (t !== undefined) p.target = doc.adopt(t);
516
+ }
517
+ if (p.currentTarget === undefined && entry.instanceId !== undefined) {
518
+ // id 0 = the island container — its facade is the doc's root.
519
+ p.currentTarget =
520
+ entry.instanceId === 0
521
+ ? doc._root
522
+ : (instances.get(entry.instanceId) !== undefined
523
+ ? doc.adopt(instances.get(entry.instanceId)!)
524
+ : undefined);
525
+ }
526
+ }
527
+ if (isImperativeInstance(instance) || isRenderedInstance(instance)) {
528
+ // No reconciler to flush — the handler's proxy-DOM mutations emit
529
+ // ops directly; runInInstance gives emit() and instance-less ops a
530
+ // queue to route to.
531
+ return runInInstance(instance.key, () => {
532
+ entry.fn(payload);
533
+ return takeOps(instance.key);
534
+ });
535
+ }
536
+ return syncCommit(instance, () => {
537
+ entry.fn(payload);
538
+ });
539
+ },
540
+
541
+ /**
542
+ * The pushed-size channel: the driver measured the island's container
543
+ * and stores it for the instance (see hostConfig instanceSizes). Imperative
544
+ * mounts additionally fire their proxy document's onResize handlers —
545
+ * inside the instance's scope so their mutations emit ops, which ride
546
+ * back in this task's return batch. React mounts have no proxy doc;
547
+ * the stored size is still what a shim-installed document would read.
548
+ */
549
+ setSize(instance: string, width: number, height: number): Op[] {
550
+ setInstanceSize(instance, width, height);
551
+ const mounted = mounts.get(instance);
552
+ if (
553
+ mounted !== undefined &&
554
+ (isImperativeInstance(mounted) || isRenderedInstance(mounted))
555
+ ) {
556
+ return runInInstance(mounted.key, () => {
557
+ const doc = isImperativeInstance(mounted)
558
+ ? mounted.imperative.doc
559
+ : mounted.rendered.doc;
560
+ (doc as InternalDocument)._notifySize(width, height);
561
+ return takeOps(instance);
562
+ });
563
+ }
564
+ return takeOps(instance);
565
+ },
566
+
567
+ /**
568
+ * Tear down ONE instance while the worker keeps serving its others —
569
+ * the multi-island-per-worker counterpart to process death: unmounts
570
+ * the React tree (or disposes the imperative instance's proxy document),
571
+ * drops the instance entry, and returns the detach op batch. Stale
572
+ * handler dispatches then no-op via the mounts.get() guard.
573
+ */
574
+ unmount(instance: string): Op[] {
575
+ const mounted = mounts.get(instance);
576
+ if (mounted === undefined) return [];
577
+ mounts.delete(instance);
578
+ if (isImperativeInstance(mounted)) {
579
+ // The app's dispose hook cancels deferred work (library timers,
580
+ // animation loops) BEFORE the doc dies — disposing the doc then
581
+ // unwinds its DOM shim and drops its handlers.
582
+ runInInstance(instance, () => {
583
+ const imp = mounted.imperative;
584
+ imp.dispose?.(imp.doc);
585
+ imp.doc.dispose();
586
+ });
587
+ return [{ t: 'clear' }];
588
+ }
589
+ if (isRenderedInstance(mounted)) {
590
+ runInInstance(instance, () => {
591
+ const r = mounted.rendered;
592
+ r.handle?.dispose?.();
593
+ r.doc.dispose();
594
+ });
595
+ return [{ t: 'clear' }];
596
+ }
597
+ return syncCommit(mounted, () => mounted.unmountTree());
598
+ },
599
+
600
+ /**
601
+ * Drain one instance's ops committed outside a sync task — passive effects
602
+ * (useEffect), timers, async setState. The pool protocol has no push
603
+ * channel, so the main thread polls this (or the doorbell pushes it).
604
+ */
605
+ flush(instance: string): Op[] {
606
+ const mounted = mounts.get(instance);
607
+ if (mounted === undefined) return [];
608
+ // Imperative and rendered mounts have no passive effects — ops
609
+ // committed outside a task (timers, continuations mutating the proxy
610
+ // DOM) just drain.
611
+ if (isImperativeInstance(mounted) || isRenderedInstance(mounted)) return takeOps(instance);
612
+ const prev = setActiveInstance(instance);
613
+ try {
614
+ mounted.flush();
615
+ return takeOps(instance);
616
+ } finally {
617
+ setActiveInstance(prev);
618
+ }
619
+ },
620
+
621
+ /** The mounted instance's random id — the island's "worker pid" badge. */
622
+ whoami(instance: string): string {
623
+ return mounts.get(instance)?.pid ?? 'unmounted';
624
+ },
625
+ },
626
+ });
627
+ }
628
+
629
+ /**
630
+ * PolyWorker — one worker script serving a REGISTRY of islands:
631
+ * `mountIsland({ app: name })` picks one per island, and every island in
632
+ * the registry shares the worker's module graph, framework runtimes, and
633
+ * op pump. This is the bundle-optimization host shape — several islands,
634
+ * one worker to load and keep warm.
635
+ */
636
+ export function definePolyWorker(
637
+ registry: PolyWorkerRegistry,
638
+ options?: { sharedMemory?: SharedMemory<DoorbellSpec> },
639
+ ): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
640
+ for (const [key, app] of Object.entries(registry.apps)) {
641
+ const stamped = islandAppNameOf(app);
642
+ if (stamped !== undefined && stamped !== key) {
643
+ console.warn(
644
+ `[definePolyWorker] app registered as "${key}" but stamped "${stamped}" — ` +
645
+ `component-reference mounts resolve "${stamped}" and will fail. Fix the key or the stamp.`,
646
+ );
647
+ }
648
+ registerSingleApp(key, app);
649
+ }
650
+ return createIslandRuntime(registry.sharedMemory ?? options?.sharedMemory);
651
+ }
652
+
653
+ /**
654
+ * MonoWorker — one worker script pinned to ONE island app (1:1). The
655
+ * isolation host shape: own bundle, own failure domain, nothing reachable
656
+ * outside the single app it serves. `mountIsland` can omit `app` — the
657
+ * worker resolves its sole app regardless of the supplied name.
658
+ */
659
+ export function defineMonoWorker(
660
+ app: IslandApp,
661
+ options?: { sharedMemory?: SharedMemory<DoorbellSpec> },
662
+ ): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
663
+ const stamp = (app as { islandAppName?: unknown }).islandAppName;
664
+ registerSingleApp(typeof stamp === 'string' && stamp !== '' ? stamp : 'main', app);
665
+ return createIslandRuntime(options?.sharedMemory);
666
+ }