@atolljs/islands 0.1.11 → 0.1.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolljs/islands",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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",
@@ -19,7 +19,7 @@
19
19
  "htmlparser2": "^12.0.0"
20
20
  },
21
21
  "peerDependencies": {
22
- "@atolljs/core": "0.1.11"
22
+ "@atolljs/core": "0.1.12"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
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
  /**
@@ -716,6 +736,10 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
716
736
  * edges stay ordered after the moves they coalesce past. */
717
737
  const COALESCE_EVENTS = new Set(['mousemove', 'pointermove', 'touchmove', 'drag', 'dragover']);
718
738
  const MAX_IN_FLIGHT_DISPATCH = 4;
739
+ /* How long destroy() waits for the worker's unmount batch before force-
740
+ * tearing down. Generous enough for a busy worker draining real tasks;
741
+ * short enough that a wedged worker is visibly gone soon. */
742
+ const UNMOUNT_GRACE_MS = 1500;
719
743
  const dispatchQueue: Array<{ handlerId: number; key: string | null; payload: EventPayload }> = [];
720
744
  let inFlightDispatches = 0;
721
745
 
@@ -1322,32 +1346,39 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
1322
1346
  measuredIds.clear();
1323
1347
  const remaining = (clientMounts.get(client) ?? 1) - 1;
1324
1348
  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
- }
1349
+ /* One bounded teardown for both cases. The worker's unmount batch is
1350
+ * still awaited so the app's dispose hook cancels deferred work and
1351
+ * the `clear` op can fire slots[name](null) for slot anchors — but
1352
+ * the wait is capped by UNMOUNT_GRACE_MS. A wedged worker (a task
1353
+ * that never settles) must not pin the worker, this island's driver
1354
+ * state, or the pending-call closures forever. finish() releases the
1355
+ * maps eagerly so ops arriving late replay against empty maps (all
1356
+ * lookups miss → no-ops), and on the last mount it terminates the
1357
+ * worker unconditionally — as long as no remount registered while
1358
+ * teardown was in flight (app/client swap does destroy → mount
1359
+ * back-to-back; terminate only when the client is truly idle). */
1360
+ let finished = false;
1361
+ const finish = () => {
1362
+ if (finished) return;
1363
+ finished = true;
1364
+ nodes.clear();
1365
+ detached.clear();
1366
+ detachedIds.clear();
1367
+ prevProps.clear();
1368
+ nodeListeners.clear();
1369
+ cbTable.clear();
1370
+ slotNodes.clear();
1371
+ dispatchQueue.length = 0;
1372
+ // keepAlive clients stay resident — the instance is unmounted but
1373
+ // the thread idles so the next mount is a warm start.
1374
+ if (remaining <= 0 && !isKeepAliveClient(client) && (clientMounts.get(client) ?? 0) === 0)
1375
+ client.terminate();
1376
+ };
1377
+ void islandCall('unmount', client.unmount(instance))
1378
+ .then((ops) => applyOps(ops, 'unmount'))
1379
+ .catch(() => {})
1380
+ .finally(finish);
1381
+ setTimeout(finish, UNMOUNT_GRACE_MS);
1351
1382
  },
1352
1383
  };
1353
1384
  // 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,10 @@ 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 clients release the instance but keep the thread warm;
654
+ // worker-owned and last-mount clients terminate outright.
655
+ if (!isKeepAliveClient(client) && (givenClient === undefined || remaining <= 0))
656
+ client.terminate();
653
657
  else void islandCall('unmount', client.unmount(instance)).catch(() => {});
654
658
  },
655
659
  };