@atolljs/islands 0.1.11 → 0.1.13

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,187 @@
1
+ /**
2
+ * Instance-scoped state shared by every renderer backend running inside a worker.
3
+ *
4
+ * - Per-instance op queues: each island/app owns a queue of {@link Op}s that the
5
+ * main-thread driver replays as real DOM mutations.
6
+ * - A shared id counter for element/text instances, used by both the React
7
+ * reconciler and the proxy DOM.
8
+ * - Handler registry for event listeners.
9
+ * - Doorbell contract used to bump the ops-version after a commit.
10
+ * - Pushed container-size geometry.
11
+ *
12
+ * This module is renderer-agnostic: it contains no react-reconciler imports.
13
+ * The React-specific host config lives in `hostConfig.ts`.
14
+ */
15
+ import type { IslandContract } from '../contract.js';
16
+ import type { Op, WireProps } from '../ops.js';
17
+ export interface ElementInstance {
18
+ kind: 'element';
19
+ id: number;
20
+ type: string;
21
+ /** Which mounted app this node belongs to — routes its ops. */
22
+ instance: string;
23
+ /** Namespace URI for non-HTML elements (svg/mathml) — rides the `create`
24
+ * op so the driver uses `createElementNS`. Tracked via host context. */
25
+ ns?: string;
26
+ /** Last serialized prop set — needed so hide/unhide can resend it. */
27
+ props: WireProps;
28
+ /** propName → stable handler id (keeps DOM listeners stable across updates). */
29
+ listenerSlots: Record<string, number>;
30
+ }
31
+ export interface TextInstance {
32
+ kind: 'text';
33
+ id: number;
34
+ text: string;
35
+ instance: string;
36
+ }
37
+ export type HostInstance = ElementInstance | TextInstance;
38
+ /** The root container is a sentinel — op `parent: 0` means "the root".
39
+ * definePolyWorker stamps `instance` on the container it hands
40
+ * createContainer, so createInstance can bind every element to the instance
41
+ * that rendered it — deterministic even when a commit runs outside a instance
42
+ * task (passive-effect renders, scheduler flushes), where activeInstance is
43
+ * ''. Instance-bound ops then always reach their island's queue. */
44
+ export interface RootContainer {
45
+ id: 0;
46
+ instance?: string;
47
+ }
48
+ export declare const ROOT_CONTAINER: RootContainer;
49
+ /**
50
+ * instance id → host record. Exported so the proxy DOM (worker/proxyDom.ts)
51
+ * shares the SAME instance space as the reconciler: a proxy-created element
52
+ * can nest inside a React-rendered parent and vice versa, because ops are
53
+ * id-addressed and ids come from one counter.
54
+ */
55
+ export declare const instances: Map<number, HostInstance>;
56
+ interface HandlerEntry {
57
+ fn: (payload: unknown) => void;
58
+ instance: string;
59
+ /** The element this handler was attached to — the event's currentTarget. */
60
+ instanceId?: number;
61
+ }
62
+ /** Move an instance record + its handler ids out of the live maps. */
63
+ export declare const detachInstance: (id: number, handlerIds: number[]) => void;
64
+ /** Restore an instance record + handler ids on re-append (idempotent). */
65
+ export declare const attachInstance: (id: number, handlerIds: number[]) => void;
66
+ /** instances.get that also resolves detached records — stale dispatches and
67
+ * relatedTarget materialization may still reference them. */
68
+ export declare const lookupInstance: (id: number) => HostInstance | undefined;
69
+ export declare const setInstanceContract: (instance: string, contract: IslandContract | undefined) => void;
70
+ /** Allocate an instance id — the shared counter React and the proxy DOM both draw from. */
71
+ export declare const allocId: () => number;
72
+ export declare const setActiveInstance: (instance: string) => string;
73
+ /** The instance the current task is running under — '' outside a instance task. */
74
+ export declare const getActiveInstance: () => string;
75
+ /**
76
+ * The most recent instance a task ran under — used by the instance dispatcher to
77
+ * route deferred library work (timers, promise continuations) to the right
78
+ * document when several mounts share one module (in-process tests).
79
+ */
80
+ export declare const getLastActiveInstance: () => string;
81
+ export declare const getLastTouchedInstance: () => string;
82
+ /**
83
+ * Marks `instance` as the ambient instance for out-of-task readers (timers,
84
+ * continuations, shim consumers outside instance work). Called by
85
+ * installDomShim so its document becomes the ambient document.
86
+ */
87
+ export declare const markInstanceActive: (instance: string) => void;
88
+ /**
89
+ * Run `fn` while `instance` is the active instance — the imperative-code twin of
90
+ * the reconciler's syncCommit wrapper. Imperative DOM writes (`emit`, the
91
+ * proxy DOM's `emit` calls, `clear`) only route correctly while a instance
92
+ * task holds the active instance: mount, updateProps, and dispatch all wrap
93
+ * their work in this, and imperative apps should do the same for any
94
+ * worker-initiated work (timers, promise continuations).
95
+ */
96
+ export declare const runInInstance: <T>(instance: string, fn: () => T) => T;
97
+ /** Queue an op onto a instance's queue — instance-bound ops pass their
98
+ * instance's instance, instance-less ops (`clear`, `emit`) the active one. */
99
+ export declare const pushOp: (instance: string, op: Op) => void;
100
+ /** The minimum surface `bumpOpsVersion` needs off the doorbell contract. */
101
+ interface DoorbellContract {
102
+ readonly bound: boolean;
103
+ connector(path: string): {
104
+ read(): unknown;
105
+ write(v: unknown): void;
106
+ };
107
+ }
108
+ /** Point the doorbell writes at a different doorbell-spec contract. */
109
+ export declare const setDoorbellContract: (contract: DoorbellContract) => void;
110
+ /**
111
+ * The doorbell write resetAfterCommit performs, exported so non-React op
112
+ * producers (the proxy DOM) can ring it too — a commit made outside any
113
+ * task still needs to wake the island's observe() loop. No-op while the
114
+ * contract is unbound (before the pool's INIT_MEMORY handshake).
115
+ */
116
+ export declare const bumpOpsVersion: () => void;
117
+ /** Drain one instance's queued ops — called by the worker's task methods. */
118
+ export declare const takeOps: (instance: string) => Op[];
119
+ /**
120
+ * Structural memory snapshot of the proxy layer — how much shadow state the
121
+ * engine retains for live nodes, handlers, and ops still queued. Removed
122
+ * nodes leave `instances` for a bounded tombstone (event dispatch may still
123
+ * resolve recently-detached target ids), so liveNodes is attached-only.
124
+ */
125
+ export declare const proxyInstanceStats: () => {
126
+ liveNodes: number;
127
+ handlers: number;
128
+ queuedOps: number;
129
+ queuedByInstance: Record<string, number>;
130
+ };
131
+ export declare const getHandler: (id: number) => HandlerEntry | undefined;
132
+ /** The instance's last pushed container size, or undefined if none arrived. */
133
+ export declare const getInstanceSize: (instance: string) => {
134
+ w: number;
135
+ h: number;
136
+ } | undefined;
137
+ /** Store the container size a `setSize` task pushed. */
138
+ export declare const setInstanceSize: (instance: string, w: number, h: number) => void;
139
+ /** The last pushed rect for one element id, or undefined if never measured. */
140
+ export declare const getInstanceRect: (instance: string, id: number) => {
141
+ x: number;
142
+ y: number;
143
+ w: number;
144
+ h: number;
145
+ } | undefined;
146
+ /** Store one element's measured box from a `setRects` push. */
147
+ export declare const setInstanceRect: (instance: string, rect: {
148
+ id: number;
149
+ x: number;
150
+ y: number;
151
+ w: number;
152
+ h: number;
153
+ }) => void;
154
+ /** Drop every measured rect for the instance — the unmount path calls this
155
+ * so stale geometry can't answer reads on a re-mounted instance's ids. */
156
+ export declare const clearInstanceRects: (instance: string) => void;
157
+ /**
158
+ * Register a function in the handler table outside prop serialization —
159
+ * the proxy DOM's addEventListener uses this. The instance is stamped on the
160
+ * entry so a dispatched event routes its re-render ops to the right queue.
161
+ * `listen`/`unlisten` ops carry the returned id so the main thread can wire
162
+ * and later detach the matching DOM listener.
163
+ */
164
+ export declare const registerHandler: (fn: (payload: unknown) => void, instance: string, instanceId?: number) => number;
165
+ export declare const unregisterHandler: (id: number) => void;
166
+ /**
167
+ * The island→shell channel. Apps call `emit(name, payload)` inside event
168
+ * handlers or commit-phase effects — it just queues an `emit` op, which the
169
+ * main-thread driver routes to the island's `onEvent` callback instead of
170
+ * the DOM. Call it while a task holds the instance (handlers, layout effects);
171
+ * a bare emit from a passive effect has no instance to route to in the
172
+ * many-mounts-per-module case and would be dropped.
173
+ */
174
+ export declare const emit: (name: string, payload?: unknown) => void;
175
+ /**
176
+ * Props must cross postMessage, so: `children` is skipped (structure comes
177
+ * from append/remove ops), `key`/`ref` are skipped, functions named `on*`
178
+ * become `{ __evt: id }`, everything else passes through structured clone.
179
+ * Each (instance, propName) pair reuses one handler-id slot, so the id the
180
+ * main thread sees never changes across re-renders — it can attach the DOM
181
+ * listener exactly once while the handler id keeps pointing at the latest
182
+ * closure.
183
+ */
184
+ export declare function serializeProps(instance: ElementInstance, props: Record<string, unknown>): WireProps;
185
+ export declare function newElement(type: string, props: Record<string, unknown>, ns: string | undefined, instance: string): ElementInstance;
186
+ export declare function newText(text: string, instance: string): TextInstance;
187
+ export {};
@@ -0,0 +1,10 @@
1
+ export { ProxyNode, ProxyText, ProxyComment, ProxyFragment, } from './dom/node.js';
2
+ export type { ProxyEventHandler, WireListenerOpts } from './dom/node.js';
3
+ export { ProxyElement, } from './dom/element.js';
4
+ export type { AdjacentPosition } from './dom/element.js';
5
+ export type { ProxyClassList } from './dom/css.js';
6
+ export { createProxyDocument, docForInstance, peekInstanceDoc, disposeInstanceDoc, } from './dom/document.js';
7
+ export type { ProxyDocument, InternalDocument, } from './dom/document.js';
8
+ export { installInstanceDispatcher, installDomShim, } from './dom/window.js';
9
+ export { ProxyResizeObserver, } from './dom/resizeObserver.js';
10
+ export type { WindowShim, WindowFacadeBundle, } from './dom/window.js';
@@ -0,0 +1,11 @@
1
+ import { type IslandHandle, type MountIslandOptions } from '../island.js';
2
+ /**
3
+ * The surface is ONE function: `mountIsland`. A proxy `el` makes the mount
4
+ * nested — these are the same options either way. `slots` receive the
5
+ * anchor's ProxyElement (worker-side content portals into it); slot names
6
+ * this mount doesn't list bubble up to the outer island's `slots`.
7
+ */
8
+ export type MountSubIslandOptions = MountIslandOptions;
9
+ /** One handle shape for both mount targets — `instance` reads `parent~app@N`. */
10
+ export type SubIslandHandle = IslandHandle;
11
+ export declare function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIslandHandle>;
package/package.json CHANGED
@@ -1,16 +1,26 @@
1
1
  {
2
2
  "name": "@atolljs/islands",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Atoll islands — MonoWorker/PolyWorker hosts rendering framework islands (React/Vue/Svelte/Solid/Angular) over a proxy-DOM op protocol",
5
5
  "homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
6
6
  "type": "module",
7
7
  "exports": {
8
- ".": "./src/index.ts",
9
- "./worker": "./src/worker/index.ts"
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./worker": {
13
+ "types": "./dist/worker/index.d.ts",
14
+ "default": "./dist/worker/index.js"
15
+ }
10
16
  },
11
17
  "files": [
12
- "src"
18
+ "src",
19
+ "dist"
13
20
  ],
21
+ "scripts": {
22
+ "build": "node ../../scripts/build-lib.mjs"
23
+ },
14
24
  "publishConfig": {
15
25
  "access": "public",
16
26
  "registry": "https://registry.npmjs.org"
@@ -19,7 +29,7 @@
19
29
  "htmlparser2": "^12.0.0"
20
30
  },
21
31
  "peerDependencies": {
22
- "@atolljs/core": "0.1.11"
32
+ "@atolljs/core": "0.1.13"
23
33
  },
24
34
  "repository": {
25
35
  "type": "git",
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * app: 'controls',
12
12
  * });
13
13
  */
14
- export { connectIslandWorker, mountIsland } from './island';
14
+ export { connectIslandWorker, isKeepAliveClient, mountIsland } from './island';
15
15
  export type {
16
16
  ConnectIslandWorkerConfig,
17
17
  IslandClient,
package/src/island.ts CHANGED
@@ -83,20 +83,40 @@ export interface ConnectIslandWorkerConfig extends IslandWorkerOptions {
83
83
  * polling and the page needs no special headers.
84
84
  */
85
85
  doorbell?: boolean;
86
+ /**
87
+ * Default false — when true, the last island to destroy on this client
88
+ * leaves the worker ALIVE but idle: the instance still unmounts (dispose
89
+ * hooks run, timers clear, ops stop, driver state releases) but the
90
+ * thread, its module graph, and any worker-side caches stay resident, so
91
+ * the next `mount` on this client is a warm start. Nothing runs while
92
+ * idle — the instance is gone; only the process survives.
93
+ */
94
+ keepAlive?: boolean;
86
95
  }
87
96
 
97
+ /* Clients opted out of idle-termination by `keepAlive` — a WeakSet so the
98
+ * flag never pins a client that would otherwise collect. */
99
+ const keepAliveClients = new WeakSet<object>();
100
+
101
+ /** True when this client stays resident after its last island destroys. */
102
+ export const isKeepAliveClient = (client: object): boolean => keepAliveClients.has(client);
103
+
88
104
  /** One island client: one dedicated worker, one doorbell buffer. */
89
105
  export const connectIslandWorker = <W extends IslandWorkerDefinition = IslandWorkerDefinition>({
90
106
  worker,
91
107
  doorbell,
108
+ keepAlive,
92
109
  ...options
93
- }: ConnectIslandWorkerConfig): WorkerClient<W, DoorbellSpec> =>
94
- connectWorker<W, DoorbellSpec>({
110
+ }: ConnectIslandWorkerConfig): WorkerClient<W, DoorbellSpec> => {
111
+ const client = connectWorker<W, DoorbellSpec>({
95
112
  ...options,
96
113
  sharedMemory: doorbell === false ? undefined : makeDoorbell(),
97
114
  worker,
98
115
  workers: 1,
99
116
  });
117
+ if (keepAlive) keepAliveClients.add(client);
118
+ return client;
119
+ };
100
120
 
101
121
  interface MountIslandBaseOptions {
102
122
  /**
@@ -161,6 +181,14 @@ interface MountIslandBaseOptions {
161
181
  * hard failures (module error, crash) already reject immediately.
162
182
  */
163
183
  mountTimeout?: number;
184
+ /**
185
+ * This mount's `destroy()` never terminates the client: the instance
186
+ * unmounts (dispose hooks, `clear` op replay) but the thread stays
187
+ * resident and idle for a warm remount. The mount-level version of
188
+ * `connectIslandWorker({ keepAlive: true })` — either flag marks the
189
+ * client warm for this island's teardown.
190
+ */
191
+ keepAlive?: boolean;
164
192
  }
165
193
 
166
194
  /**
@@ -716,6 +744,10 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
716
744
  * edges stay ordered after the moves they coalesce past. */
717
745
  const COALESCE_EVENTS = new Set(['mousemove', 'pointermove', 'touchmove', 'drag', 'dragover']);
718
746
  const MAX_IN_FLIGHT_DISPATCH = 4;
747
+ /* How long destroy() waits for the worker's unmount batch before force-
748
+ * tearing down. Generous enough for a busy worker draining real tasks;
749
+ * short enough that a wedged worker is visibly gone soon. */
750
+ const UNMOUNT_GRACE_MS = 1500;
719
751
  const dispatchQueue: Array<{ handlerId: number; key: string | null; payload: EventPayload }> = [];
720
752
  let inFlightDispatches = 0;
721
753
 
@@ -1322,32 +1354,41 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
1322
1354
  measuredIds.clear();
1323
1355
  const remaining = (clientMounts.get(client) ?? 1) - 1;
1324
1356
  clientMounts.set(client, remaining);
1325
- if (remaining > 0) {
1326
- // The worker still serves sibling mounts — hand this one back, but
1327
- // replay the teardown batch anyway: the `clear` op unmounts slot
1328
- // anchors inside our tree, firing slots[name](null) so slot render
1329
- // roots come down. Dropping it would leave a slot-hosted sibling
1330
- // mounted on this client and pin the worker alive.
1331
- void islandCall('unmount', client.unmount(instance))
1332
- .then((ops) => applyOps(ops, 'unmount'))
1333
- .catch(() => {});
1334
- } else {
1335
- // Last island — still run the instance's teardown first so the app's
1336
- // dispose hook cancels deferred work (library timers, animation
1337
- // loops) and the proxy doc unwinds. In a real worker terminate may
1338
- // kill the thread before unmount lands — equally fine, process
1339
- // death is teardown — but in-process workers need this or the
1340
- // instance zombies on and its pending callbacks mutate foreign docs.
1341
- void islandCall('unmount', client.unmount(instance))
1342
- .then((ops) => applyOps(ops, 'unmount'))
1343
- .catch(() => {})
1344
- .finally(() => {
1345
- // A remount may have registered while teardown was in flight
1346
- // (app/client input swap does destroy → mount back-to-back) —
1347
- // terminate only when the client is still truly idle.
1348
- if ((clientMounts.get(client) ?? 0) === 0) client.terminate();
1349
- });
1350
- }
1357
+ /* One bounded teardown for both cases. The worker's unmount batch is
1358
+ * still awaited so the app's dispose hook cancels deferred work and
1359
+ * the `clear` op can fire slots[name](null) for slot anchors — but
1360
+ * the wait is capped by UNMOUNT_GRACE_MS. A wedged worker (a task
1361
+ * that never settles) must not pin the worker, this island's driver
1362
+ * state, or the pending-call closures forever. finish() releases the
1363
+ * maps eagerly so ops arriving late replay against empty maps (all
1364
+ * lookups miss → no-ops), and on the last mount it terminates the
1365
+ * worker unconditionally — as long as no remount registered while
1366
+ * teardown was in flight (app/client swap does destroy → mount
1367
+ * back-to-back; terminate only when the client is truly idle). */
1368
+ let finished = false;
1369
+ const finish = () => {
1370
+ if (finished) return;
1371
+ finished = true;
1372
+ nodes.clear();
1373
+ detached.clear();
1374
+ detachedIds.clear();
1375
+ prevProps.clear();
1376
+ nodeListeners.clear();
1377
+ cbTable.clear();
1378
+ slotNodes.clear();
1379
+ dispatchQueue.length = 0;
1380
+ // keepAlive (client-level flag or this mount's override) keeps the
1381
+ // thread resident — the instance is unmounted but the worker idles
1382
+ // so the next mount is a warm start.
1383
+ const warm = opts.keepAlive === true || isKeepAliveClient(client);
1384
+ if (remaining <= 0 && !warm && (clientMounts.get(client) ?? 0) === 0)
1385
+ client.terminate();
1386
+ };
1387
+ void islandCall('unmount', client.unmount(instance))
1388
+ .then((ops) => applyOps(ops, 'unmount'))
1389
+ .catch(() => {})
1390
+ .finally(finish);
1391
+ setTimeout(finish, UNMOUNT_GRACE_MS);
1351
1392
  },
1352
1393
  };
1353
1394
  // Dashboard commands (`island.*`) — registered only while devtools is on.
@@ -37,6 +37,7 @@ import {
37
37
  assertCloneableProps,
38
38
  connectIslandWorker,
39
39
  cssStyleValue,
40
+ isKeepAliveClient,
40
41
  registerSubIslandMounter,
41
42
  type ConnectIslandWorkerConfig,
42
43
  type IslandClient,
@@ -649,7 +650,11 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
649
650
  if (pollTimer !== null) clearInterval(pollTimer);
650
651
  const remaining = (clientMounts.get(client) ?? 1) - 1;
651
652
  clientMounts.set(client, remaining);
652
- if (givenClient === undefined || remaining <= 0) client.terminate();
653
+ // keepAlive (client-level or this mount's override) releases the
654
+ // instance but keeps the thread warm; worker-owned and last-mount
655
+ // clients terminate outright.
656
+ const warm = opts.keepAlive === true || isKeepAliveClient(client);
657
+ if (!warm && (givenClient === undefined || remaining <= 0)) client.terminate();
653
658
  else void islandCall('unmount', client.unmount(instance)).catch(() => {});
654
659
  },
655
660
  };