@atolljs/islands 0.1.0 → 0.1.2

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/metrics.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Internal bookkeeping for measuring the island engine's own cost, separate
3
+ * from application/render work. Disabled by default — the flag check is one
4
+ * predictable branch per chokepoint, so production mounts pay nothing.
5
+ *
6
+ * The one subtlety is re-entrancy: a proxy-DOM method like `el.appendChild`
7
+ * calls `pushOp`, which is itself instrumented. Timing every frame would
8
+ * double-count, so `enter`/`exit` share a depth counter — only the
9
+ * outermost instrumented frame accumulates. The residual
10
+ * (`workerMs − recordMs`) is then application/adapter time.
11
+ *
12
+ * The bench spec additionally calls {@link instrumentPrototype} on the
13
+ * worker-side dom/* classes so imperative-app shadow-tree maintenance is
14
+ * captured too; framework adapters reach the same sinks directly.
15
+ */
16
+ export const proxyMetrics = {
17
+ enabled: false,
18
+ /** Frames currently inside instrumented proxy code (re-entrancy guard). */
19
+ depth: 0,
20
+ /** ms inside outermost instrumented frames since reset(). */
21
+ recordMs: 0,
22
+ /** Ops pushed since reset() — a count, free to keep. */
23
+ opsPushed: 0,
24
+ reset(): void {
25
+ this.depth = 0;
26
+ this.recordMs = 0;
27
+ this.opsPushed = 0;
28
+ },
29
+ };
30
+
31
+ /**
32
+ * Enter an instrumented frame — returns a start timestamp for the outermost
33
+ * frame, or -1 when nested (in which case exit records nothing).
34
+ */
35
+ export const enterProxyFrame = (): number => {
36
+ proxyMetrics.depth++;
37
+ return proxyMetrics.depth === 1 ? performance.now() : -1;
38
+ };
39
+
40
+ /** Pair with {@link enterProxyFrame} — accumulates only for t0 >= 0. */
41
+ export const exitProxyFrame = (t0: number): void => {
42
+ proxyMetrics.depth--;
43
+ if (t0 >= 0) proxyMetrics.recordMs += performance.now() - t0;
44
+ };
45
+
46
+ /**
47
+ * Timing wrapper for a function — no-ops to a direct call while metrics are
48
+ * disabled. Used to wrap dom/* prototype methods from the bench.
49
+ */
50
+ const wrapTimed = <A extends unknown[], R>(fn: (...args: A) => R) =>
51
+ function (this: unknown, ...args: A): R {
52
+ if (!proxyMetrics.enabled) return fn.apply(this, args);
53
+ const t0 = enterProxyFrame();
54
+ try {
55
+ return fn.apply(this, args);
56
+ } finally {
57
+ exitProxyFrame(t0);
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Wrap every method/getter/setter on a class prototype with timing. Live
63
+ * for the rest of the process — intended for benchmark instrumentation,
64
+ * not production.
65
+ */
66
+ export function instrumentPrototype(proto: object): void {
67
+ for (const [name, desc] of Object.entries(
68
+ Object.getOwnPropertyDescriptors(proto),
69
+ )) {
70
+ if (name === 'constructor') continue;
71
+ const wrapped: PropertyDescriptor = { ...desc };
72
+ if (typeof desc.value === 'function') {
73
+ wrapped.value = wrapTimed(desc.value as (...args: unknown[]) => unknown);
74
+ }
75
+ if (desc.get) wrapped.get = wrapTimed(desc.get);
76
+ if (desc.set) wrapped.set = wrapTimed(desc.set);
77
+ Object.defineProperty(proto, name, wrapped);
78
+ }
79
+ }
@@ -40,10 +40,13 @@
40
40
  * flush = drain only). See worker/proxyDom.ts.
41
41
  *
42
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.
43
+ * for EVERY framework renderer, React included — mount(ctx) renders into the
44
+ * instance's proxy document and returns a handle whose `update(props)` takes
45
+ * over updateProps (fine-grained patching instead of rebuild), whose
46
+ * `sync(fn)`/`flush()` let task-ordered renderers (react-reconciler) pin
47
+ * commits into the task batch, and whose `dispose()` runs before the
48
+ * document dies. This package ships no renderer: React's lives in
49
+ * `@atolljs/react-island/worker` — framework-neutral by construction.
47
50
  *
48
51
  * Ops still ride back through the pool's ordinary postMessage channel — the
49
52
  * sharedMemory contract here is only the doorbell (see memory.ts): a commit
@@ -52,8 +55,6 @@
52
55
  * message-only mode.
53
56
  */
54
57
 
55
- import type { ReactElement } from 'react';
56
- import type { ReactInstance } from './reactInstance';
57
58
  import { defineWorker } from '@atolljs/core';
58
59
  import type { SharedMemory, WorkerDefinition } from '@atolljs/core';
59
60
  import { islandAppNameOf } from '../app';
@@ -63,6 +64,8 @@ import {
63
64
  createProxyDocument,
64
65
  installInstanceDispatcher,
65
66
  docForInstance,
67
+ peekInstanceDoc,
68
+ disposeInstanceDoc,
66
69
  type InternalDocument,
67
70
  type ProxyDocument,
68
71
  } from './proxyDom';
@@ -72,7 +75,6 @@ import {
72
75
  instances,
73
76
  pushOp,
74
77
  runInInstance,
75
- setActiveInstance,
76
78
  setDoorbellContract,
77
79
  setInstanceSize,
78
80
  takeOps,
@@ -81,13 +83,13 @@ import type { EventPayload, IslandWorkerMethods, Op } from '../ops';
81
83
 
82
84
  /**
83
85
  * 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.
86
+ * - `{ imperative: (doc, props) => void }` — no framework at all. mount()
87
+ * hands it a proxy DOM scoped to the instance; its mutations emit ops
88
+ * directly.
89
+ * - `{ mount: (ctx) => handle | void }` — a framework adapter's output
90
+ * (`reactIslandApp`, `vueIslandApp`, …). Bare component functions are NOT
91
+ * registry values — wrap them first (defineReactPolyWorker does it).
89
92
  */
90
- export type ReactIslandApp = (props: any) => ReactElement;
91
93
  export interface ImperativeIslandApp {
92
94
  imperative: (doc: ProxyDocument, props: Record<string, unknown>) => void;
93
95
  /**
@@ -117,6 +119,21 @@ export interface RenderedHandle {
117
119
  * clear + re-mount on a fresh document.
118
120
  */
119
121
  update?(props: Record<string, unknown>): void;
122
+ /**
123
+ * Run `fn` inside the renderer's synchronous commit lane (React:
124
+ * flushSyncFromReconciler + flushSyncWork) so its mutations emit ops
125
+ * inside the current task batch. Dispatch wraps event-handler invocation
126
+ * in this when the handle provides one; renderers without a sync lane
127
+ * simply omit it.
128
+ */
129
+ sync?(fn: () => void): void;
130
+ /**
131
+ * Drain renderer work scheduled outside tasks — passive effects, async
132
+ * state updates (React: flushPassiveEffects + flushSyncWork). The worker's
133
+ * flush() method calls it inside the instance's scope, then drains the
134
+ * op queue.
135
+ */
136
+ flush?(): void;
120
137
  /**
121
138
  * App teardown — runs inside the instance's scope BEFORE its proxy document
122
139
  * is disposed (unmount, remount, updateProps rebuild). Same rules as
@@ -125,18 +142,18 @@ export interface RenderedHandle {
125
142
  dispose?(): void;
126
143
  }
127
144
  /**
128
- * A renderer-backed app: a non-React framework renderer (Vue, Svelte,
145
+ * A renderer-backed app: a framework renderer (React, Vue, Svelte,
129
146
  * Solid, Angular) whose `mount` renders component output into the instance's
130
147
  * 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.
148
+ * adapters (e.g. @atolljs/react-island/worker, @atolljs/vue-island/worker)
149
+ * wrap a component into this shape so it can sit beside imperative apps in
150
+ * the same `apps` registry.
134
151
  */
135
152
  export interface RenderedIslandApp {
136
153
  mount(ctx: RenderContext): RenderedHandle | void;
137
154
  }
138
155
  /** The apps an island can mount — keyed by the name the shell passes to mount(). */
139
- export type IslandApp = ReactIslandApp | ImperativeIslandApp | RenderedIslandApp;
156
+ export type IslandApp = ImperativeIslandApp | RenderedIslandApp;
140
157
 
141
158
  export interface PolyWorkerRegistry {
142
159
  /** Name → island app registry. The shell's `mountIsland({ app: name })`
@@ -201,14 +218,18 @@ interface RenderedInstance extends InstanceBase {
201
218
  app: RenderedIslandApp;
202
219
  /** The mount-returned handle — drives fine-grained updates/teardown. */
203
220
  handle: RenderedHandle | void;
204
- /** The instance's proxy document — replaced on each rebuild. */
205
- doc: ProxyDocument;
206
221
  props: Record<string, unknown>;
207
222
  };
223
+ // The instance's proxy document is NOT stored here: it lives in
224
+ // instanceDocs, materialized lazily by `ctx.doc` or `docForInstance`
225
+ // adoption (React mounts only need one when a library holds a ref or a
226
+ // portal target) — rendered instances without a doc preserve the
227
+ // ambient-document resolution semantics imperative instances rely on.
228
+ // Look it up with peekInstanceDoc; tear it down with disposeInstanceDoc.
208
229
  }
209
230
 
210
231
  /** A mounted instance — one per island worker in production. */
211
- export type Instance = ReactInstance | ImperativeInstance | RenderedInstance;
232
+ export type Instance = ImperativeInstance | RenderedInstance;
212
233
 
213
234
  const isImperativeInstance = (r: Instance): r is ImperativeInstance => r.imperative !== undefined;
214
235
  const isRenderedInstance = (r: Instance): r is RenderedInstance => r.rendered !== undefined;
@@ -240,15 +261,6 @@ const resolveApp = (name: string): IslandApp | undefined =>
240
261
 
241
262
  const newPid = (): string => `w-${Math.random().toString(36).slice(2, 8)}`;
242
263
 
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
264
  /** 'controls' or 'data-table@7' → 'data-table' — registry name part of a instance key. */
253
265
  const appNameOf = (instance: string): string => {
254
266
  const at = instance.lastIndexOf('@');
@@ -274,10 +286,10 @@ function createIslandRuntime(
274
286
  // installDomShim, which builds on this.
275
287
  installInstanceDispatcher();
276
288
 
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.
289
+ // Container creation is per-instance, not module-level, so a second mount()
290
+ // can't collide with the first instance's tree. Imperative mounts skip the
291
+ // renderer entirely — their "host environment" is the proxy DOM, and
292
+ // build() emits ops itself.
281
293
  function createInstance(key: string, pid: string): Instance {
282
294
  const app = resolveApp(appNameOf(key));
283
295
  if (isRendered(app)) {
@@ -285,7 +297,7 @@ function createIslandRuntime(
285
297
  key,
286
298
  app: appNameOf(key),
287
299
  pid,
288
- rendered: { app, handle: undefined, doc: createProxyDocument(key), props: {} },
300
+ rendered: { app, handle: undefined, props: {} },
289
301
  };
290
302
  }
291
303
  if (isImperative(app)) {
@@ -302,32 +314,12 @@ function createIslandRuntime(
302
314
  };
303
315
  }
304
316
  throw new Error(
305
- `createInstance: "${appNameOf(key)}" resolved to a React app — React mounts are created by reactInstance (lazy import), this path is unreachable`,
317
+ `createInstance: "${appNameOf(key)}" is a bare component — registry values must be ` +
318
+ `{ imperative } or a framework adapter's RenderedIslandApp ` +
319
+ `(reactIslandApp/vueIslandApp/…, or the package's define*PolyWorker which wraps for you)`,
306
320
  );
307
321
  }
308
322
 
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
323
  /**
332
324
  * Rebuild an imperative instance — the simplest honest updateProps/remount
333
325
  * semantics for code with no reconciler: dispose the old document (its
@@ -355,15 +347,27 @@ function createIslandRuntime(
355
347
  * the proxy document, `clear`, then re-mount on a fresh document. Used
356
348
  * for remounts and for updateProps when the handle exposes no `update`.
357
349
  */
350
+ /** The RenderContext handed to `app.mount` — `doc` materializes the
351
+ * instance's proxy document on first access so renderers that never
352
+ * touch it (React) register nothing in instanceDocs. */
353
+ function renderCtx(key: string, props: Record<string, unknown>): RenderContext {
354
+ return {
355
+ instance: key,
356
+ get doc() {
357
+ return docForInstance(key);
358
+ },
359
+ props,
360
+ };
361
+ }
362
+
358
363
  function rebuildRendered(instance: RenderedInstance, props: Record<string, unknown>): Op[] {
359
364
  return runInInstance(instance.key, () => {
360
365
  const r = instance.rendered;
361
366
  r.handle?.dispose?.();
362
- r.doc.dispose();
367
+ disposeInstanceDoc(instance.key);
363
368
  pushOp(instance.key, { t: 'clear' });
364
- r.doc = createProxyDocument(instance.key);
365
369
  r.props = props;
366
- r.handle = r.app.mount({ instance: instance.key, doc: r.doc, props });
370
+ r.handle = r.app.mount(renderCtx(instance.key, props));
367
371
  return takeOps(instance.key);
368
372
  });
369
373
  }
@@ -394,71 +398,40 @@ function createIslandRuntime(
394
398
  );
395
399
  }
396
400
 
397
- let mounted = mounts.get(instance);
398
- // Imperative/rendered remount — same clear+rebuild semantics as
399
- // updateProps; the instance (and pid) survives.
401
+ const mounted = mounts.get(instance);
402
+ // Remount — same clear+rebuild semantics as updateProps; the instance
403
+ // (and pid) survives.
400
404
  if (mounted !== undefined && isImperativeInstance(mounted)) {
401
405
  return rebuildImperative(mounted, props);
402
406
  }
403
- if (mounted !== undefined && isRenderedInstance(mounted)) {
407
+ if (mounted !== undefined) {
404
408
  return rebuildRendered(mounted, props);
405
409
  }
406
410
 
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);
411
+ // First mount. (A registry entry's kind can't change under a mounted
412
+ // instance key — the registry is fixed at module load.)
413
+ const created = createInstance(instance, newPid());
414
+ mounts.set(instance, created);
438
415
 
439
- if (isImperativeInstance(mounted)) {
416
+ if (isImperativeInstance(created)) {
440
417
  // First mount of an imperative instance — run build() in the instance's
441
418
  // scope and drain the ops its proxy-DOM mutations emitted.
442
- const imp = mounted.imperative;
419
+ const imp = created.imperative;
443
420
  imp.props = props;
444
- return ops.concat(
445
- runInInstance(instance, () => {
446
- imp.build(imp.doc, props);
447
- return takeOps(instance);
448
- }),
449
- );
421
+ return runInInstance(instance, () => {
422
+ imp.build(imp.doc, props);
423
+ return takeOps(instance);
424
+ });
450
425
  }
451
426
 
452
427
  // First mount of a rendered instance — mount() in the instance's scope;
453
428
  // the framework's proxy-DOM mutations emit the initial op batch.
454
- const r = (mounted as RenderedInstance).rendered;
429
+ const r = created.rendered;
455
430
  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
- );
431
+ return runInInstance(instance, () => {
432
+ r.handle = r.app.mount(renderCtx(instance, props));
433
+ return takeOps(instance);
434
+ });
462
435
  },
463
436
 
464
437
  /**
@@ -478,17 +451,13 @@ function createIslandRuntime(
478
451
  if (isImperativeInstance(mounted)) return rebuildImperative(mounted, props);
479
452
  // Rendered mounts prefer their own fine-grained update; absent a
480
453
  // 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));
454
+ const r = mounted.rendered;
455
+ if (r.handle?.update === undefined) return rebuildRendered(mounted, props);
456
+ return runInInstance(instance, () => {
457
+ r.handle!.update!(props);
458
+ r.props = props;
459
+ return takeOps(instance);
460
+ });
492
461
  },
493
462
 
494
463
  /**
@@ -524,77 +493,69 @@ function createIslandRuntime(
524
493
  : undefined);
525
494
  }
526
495
  }
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);
496
+ return runInInstance(instance.key, () => {
497
+ // Task-ordered renderers (React) wrap the handler in their sync
498
+ // lane — the handle's `sync` hook — so handler-driven commits emit
499
+ // ops inside this dispatch batch. Renderers without one flush their
500
+ // work through the doorbell/flush path instead.
501
+ const sync = isRenderedInstance(instance) ? instance.rendered.handle?.sync : undefined;
502
+ if (sync !== undefined) sync(() => entry.fn(payload));
503
+ else entry.fn(payload);
504
+ return takeOps(instance.key);
538
505
  });
539
506
  },
540
507
 
541
508
  /**
542
509
  * 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 —
510
+ * and stores it for the instance (see instanceSizes). Mounts
511
+ * additionally fire their proxy document's onResize handlers —
545
512
  * 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.
513
+ * back in this task's return batch.
548
514
  */
549
515
  setSize(instance: string, width: number, height: number): Op[] {
550
516
  setInstanceSize(instance, width, height);
551
517
  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);
518
+ if (mounted === undefined) return takeOps(instance);
519
+ const doc = isImperativeInstance(mounted)
520
+ ? mounted.imperative.doc
521
+ : peekInstanceDoc(instance);
522
+ // Rendered mounts that never materialized their document (React)
523
+ // have no onResize listeners — the stored size still answers shim
524
+ // reads, so just drain.
525
+ if (doc === undefined) return takeOps(instance);
526
+ return runInInstance(mounted.key, () => {
527
+ (doc as InternalDocument)._notifySize(width, height);
528
+ return takeOps(instance);
529
+ });
565
530
  },
566
531
 
567
532
  /**
568
533
  * 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
534
+ * the multi-island-per-worker counterpart to process death: disposes
535
+ * the renderer handle (or the imperative instance's proxy document),
536
+ * drops the instance entry, and returns the clear op. Stale
572
537
  * handler dispatches then no-op via the mounts.get() guard.
573
538
  */
574
539
  unmount(instance: string): Op[] {
575
540
  const mounted = mounts.get(instance);
576
541
  if (mounted === undefined) return [];
577
542
  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, () => {
543
+ return runInInstance(instance, () => {
544
+ if (isImperativeInstance(mounted)) {
545
+ // The app's dispose hook cancels deferred work (library timers,
546
+ // animation loops) BEFORE the doc dies — disposing the doc then
547
+ // unwinds its DOM shim and drops its handlers.
583
548
  const imp = mounted.imperative;
584
549
  imp.dispose?.(imp.doc);
585
550
  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());
551
+ } else {
552
+ mounted.rendered.handle?.dispose?.();
553
+ disposeInstanceDoc(instance);
554
+ }
555
+ // Teardown ops die with the instance — the clear op covers them.
556
+ takeOps(instance);
557
+ return [{ t: 'clear' as const }];
558
+ });
598
559
  },
599
560
 
600
561
  /**
@@ -605,17 +566,16 @@ function createIslandRuntime(
605
566
  flush(instance: string): Op[] {
606
567
  const mounted = mounts.get(instance);
607
568
  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();
569
+ // Imperative mounts commit nothing outside tasks — ops pushed by
570
+ // timers/continuations just drain. Rendered mounts flush their
571
+ // renderer first (React: passive effects + scheduled sync work) so
572
+ // those commits drain in the same batch.
573
+ if (isImperativeInstance(mounted)) return takeOps(instance);
574
+ const r = mounted.rendered;
575
+ return runInInstance(mounted.key, () => {
576
+ r.handle?.flush?.();
615
577
  return takeOps(instance);
616
- } finally {
617
- setActiveInstance(prev);
618
- }
578
+ });
619
579
  },
620
580
 
621
581
  /** The mounted instance's random id — the island's "worker pid" badge. */
@@ -569,6 +569,19 @@ export const createProxyDocument = (instance: string): ProxyDocument => {
569
569
  export const docForInstance = (instance: string): InternalDocument =>
570
570
  instanceDocs.get(instance) ?? (createProxyDocument(instance) as InternalDocument);
571
571
 
572
+ /** The instance's proxy document if one was materialized — never creates.
573
+ * Rendered instances may hold none (React's host config writes instance
574
+ * records, not proxy nodes, until a ref/portal adopts them). */
575
+ export const peekInstanceDoc = (instance: string): InternalDocument | undefined =>
576
+ instanceDocs.get(instance);
577
+
578
+ /** Dispose the instance's proxy document if one exists — covers docs
579
+ * materialized through `docForInstance`, not just `ctx.doc`, so unmount
580
+ * can't leave a stale entry for ambient-document resolution. */
581
+ export const disposeInstanceDoc = (instance: string): void => {
582
+ instanceDocs.get(instance)?.dispose();
583
+ };
584
+
572
585
  /**
573
586
  * The document `globalThis.document` should mean right now: the active
574
587
  * instance's when a task holds one; the single registered document when only
@@ -5,9 +5,14 @@
5
5
  * import { definePolyWorker } from '@atolljs/islands/worker';
6
6
  * export const renderWorker = definePolyWorker({ apps: { ... } });
7
7
  *
8
- * Apps (React or `{ imperative }`) also use from here: `emit` (island→shell
9
- * channel), `runInInstance` (instance scoping for worker-initiated work), `Slot`
10
- * (transclusion), `createProxyDocument`/`installDomShim` (imperative DOM).
8
+ * Framework apps also use from here: `emit` (island→shell
9
+ * channel), `runInInstance` (instance scoping for worker-initiated work),
10
+ * `createProxyDocument`/`installDomShim` (imperative DOM), plus the
11
+ * renderer-facing instance factories (`newElement`/`newText`/`serializeProps`,
12
+ * `ROOT_CONTAINER`) the `*-island` worker adapters drive.
13
+ *
14
+ * Framework-neutral by construction: this entry imports no framework — React's
15
+ * reconciler adapter lives in `@atolljs/react-island/worker`.
11
16
  */
12
17
  export { definePolyWorker, defineMonoWorker } from './defineWorkers';
13
18
  export type {
@@ -15,7 +20,6 @@ export type {
15
20
  Instance,
16
21
  IslandApp,
17
22
  PolyWorkerRegistry,
18
- ReactIslandApp,
19
23
  RenderContext,
20
24
  RenderedHandle,
21
25
  RenderedIslandApp,
@@ -23,10 +27,6 @@ export type {
23
27
 
24
28
  export { islandApp, islandAppNameOf } from '../app';
25
29
 
26
- // hostConfig is deliberately NOT exported — a static re-export would pull
27
- // react + react-reconciler into every /worker bundle. The reconciler is
28
- // reachable only through definePolyWorker's lazy import of reactInstance.
29
-
30
30
  export {
31
31
  allocId,
32
32
  bumpOpsVersion,
@@ -37,17 +37,20 @@ export {
37
37
  getLastTouchedInstance,
38
38
  getInstanceSize,
39
39
  instances,
40
+ newElement,
41
+ newText,
40
42
  pushOp,
41
43
  registerHandler,
42
44
  ROOT_CONTAINER,
43
45
  runInInstance,
46
+ serializeProps,
44
47
  setActiveInstance,
45
48
  setDoorbellContract,
46
49
  setInstanceSize,
47
50
  takeOps,
48
51
  unregisterHandler,
49
52
  } from './instance';
50
- export type { ElementInstance, HostInstance, TextInstance } from './instance';
53
+ export type { ElementInstance, HostInstance, RootContainer, TextInstance } from './instance';
51
54
 
52
55
  export {
53
56
  createProxyDocument,
@@ -70,8 +73,6 @@ export type {
70
73
  WireListenerOpts,
71
74
  } from './proxyDom';
72
75
 
73
- export { Slot } from './slot';
74
-
75
76
  export type { EventPayload, IslandWorkerMethods, Op, WireProps } from '../ops';
76
77
  export { isEventRef } from '../ops';
77
78
  export { renderMemory } from '../memory';