@atolljs/islands 0.1.7 → 0.1.9

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/island.ts CHANGED
@@ -1,20 +1,30 @@
1
1
  /**
2
2
  * Per-island mounter — the main-thread half of one React tree in one worker.
3
3
  *
4
- * `mountIsland` mounts one island scoped so several can coexist on one page:
5
- * each island gets its own `connectWorker` client (own pool of ONE worker,
6
- * own doorbell buffer via `makeDoorbell()`), its own nodes/props/listeners
7
- * maps (op ids are only unique within a worker — sharing maps between
8
- * islands would corrupt them), and its own `onEvent` sink for `emit` ops.
4
+ * `mountIsland` mounts one island scoped so several can coexist on one page.
5
+ * A `worker:` mount builds its own client (one dedicated worker, own
6
+ * doorbell buffer via `makeDoorbell()`), i.e. spawns a worker; a `client:`
7
+ * mount adds an instance to that client's existing worker instead. Either
8
+ * way each island gets its own nodes/props/listeners maps (op ids are only
9
+ * unique within a worker — sharing maps between islands would corrupt them),
10
+ * and its own `onEvent` sink for `emit` ops.
9
11
  *
10
12
  * There is no React on this thread — the driver below just replays ops.
11
13
  *
12
- * poolSize: 1 is REQUIRED per island — the reconciled tree lives in one
13
- * worker's memory. A real pool can't serve islands today anyway: task routing
14
- * is least-busy round-robin, so a second worker would receive dispatches for
15
- * a tree it doesn't hold (sticky routing is future work).
14
+ * One worker per island client is REQUIRED — the reconciled tree lives in
15
+ * one worker's memory. A pool can't serve islands: task routing is
16
+ * least-busy, so a second worker would receive dispatches for a tree it
17
+ * doesn't hold. Island clients therefore run on `workers: 1`, a dedicated
18
+ * worker with no pool.
16
19
  */
17
- import { connectWorker, emitDevtools, observe } from '@atolljs/core';
20
+ import {
21
+ connectWorker,
22
+ devtoolsEnabled,
23
+ emitDevtools,
24
+ estimateCloneBytes,
25
+ observe,
26
+ previewValue,
27
+ } from '@atolljs/core';
18
28
  import type {
19
29
  ConnectWorkerConfig,
20
30
  SharedSpec,
@@ -31,27 +41,31 @@ import {
31
41
  } from './ops';
32
42
 
33
43
  import { marshalCallbackProps, CALLBACK_EVENT } from './callbackProps';
44
+ import { trackIslandForDevtools } from './devtoolsCommands';
45
+ import { propsPreview } from './devtoolsPreview';
46
+ import type { ProxyElement } from './worker/dom/element';
34
47
 
35
48
  export type Mode = 'push' | 'poll';
36
49
 
37
50
  /** The worker definition every `definePolyWorker` call produces. */
38
51
  export type IslandWorkerDefinition = WorkerDefinition<DoorbellSpec, IslandWorkerMethods>;
39
52
 
40
- /** One island client: one pool, one worker, one doorbell buffer. */
53
+ /** One island client: one dedicated worker, one doorbell buffer. */
41
54
  export type IslandClient = WorkerClient<IslandWorkerDefinition, DoorbellSpec>;
42
55
 
43
56
  /**
44
- * Islands are microfrontend containers: each owns ONE worker holding ONE
45
- * reconciled tree, so pooling is disabled by construction — the type omits
46
- * `poolSize`/`worker`/`sharedMemory` (all island-internal) and the literal
47
- * below pins `poolSize: 1` after the spread, so a wider pool can't sneak
57
+ * An island client is ONE worker holding its islands' trees (one per mount
58
+ * on it), so pooling is disabled by construction — the type omits
59
+ * `workers`/`poolSize`/`worker`/`sharedMemory` (all island-internal) and the
60
+ * literal below pins `workers: 1` after the spread, so a pool can't sneak
48
61
  * through a cast either. `worker` is supplied per call — the package can't
49
62
  * know where the consumer's worker entry lives — everything else
50
- * (concurrency, taskTimeout, respawn, lazy…) still passes through.
63
+ * (taskTimeout, respawn, lazy…) still passes through. The pool-only
64
+ * `concurrency`/`maxQueue` are accepted but have no effect.
51
65
  */
52
66
  export type IslandWorkerOptions = Omit<
53
67
  ConnectWorkerConfig<SharedSpec>,
54
- 'sharedMemory' | 'worker' | 'poolSize'
68
+ 'sharedMemory' | 'worker' | 'workers' | 'poolSize'
55
69
  >;
56
70
 
57
71
  export interface ConnectIslandWorkerConfig extends IslandWorkerOptions {
@@ -64,13 +78,13 @@ export interface ConnectIslandWorkerConfig extends IslandWorkerOptions {
64
78
  /**
65
79
  * Default true — the client carries a SharedArrayBuffer doorbell for
66
80
  * push-mode flush (requires COOP/COEP cross-origin isolation). Pass
67
- * `false` for a message-only pool: `setMode('push')` falls back to
81
+ * `false` for a message-only client: `setMode('push')` falls back to
68
82
  * polling and the page needs no special headers.
69
83
  */
70
84
  doorbell?: boolean;
71
85
  }
72
86
 
73
- /** One island client: one pool, one worker, one doorbell buffer. */
87
+ /** One island client: one dedicated worker, one doorbell buffer. */
74
88
  export const connectIslandWorker = <W extends IslandWorkerDefinition = IslandWorkerDefinition>({
75
89
  worker,
76
90
  doorbell,
@@ -80,12 +94,17 @@ export const connectIslandWorker = <W extends IslandWorkerDefinition = IslandWor
80
94
  ...options,
81
95
  sharedMemory: doorbell === false ? undefined : makeDoorbell(),
82
96
  worker,
83
- poolSize: 1,
97
+ workers: 1,
84
98
  });
85
99
 
86
100
  interface MountIslandBaseOptions {
87
- /** Container element the island's ops are applied into. */
88
- el: HTMLElement;
101
+ /**
102
+ * Container element the island's ops are applied into — a real element on
103
+ * the shell side, or a `ProxyElement` inside a worker-rendered island
104
+ * (nested mount: the sub-island's ops replay into the parent instance's
105
+ * shadow tree and tunnel upward through its op stream). Required.
106
+ */
107
+ el: HTMLElement | ProxyElement;
89
108
  /**
90
109
  * Registry app name — a key of the `apps` map passed to
91
110
  * `definePolyWorker`. Optional against a instance worker
@@ -103,6 +122,10 @@ interface MountIslandBaseOptions {
103
122
  * events, zero wire). Called again with `null` when the worker removes or
104
123
  * renames the element, so the shell can tear down. The element itself
105
124
  * stays worker-owned — its box/layout props apply normally.
125
+ *
126
+ * Nested mounts (a ProxyElement `el`) receive the anchor's ProxyElement
127
+ * instead, and only claim names present in this object (`name in slots`);
128
+ * unclaimed slot names bubble up to the outer island's `slots`.
106
129
  */
107
130
  slots?: Record<string, (el: HTMLElement | null) => void>;
108
131
  /** Fired after each applied op batch — the shell uses it for stats. */
@@ -123,9 +146,10 @@ interface MountIslandBaseOptions {
123
146
  mode?: Mode;
124
147
  /**
125
148
  * Optional renderer/framework tag — pure observability metadata forwarded
126
- * to devtools on `island:mount` ('react', 'vue', 'svelte', …). Framework
127
- * shell bindings can set it automatically; direct mountIsland callers
128
- * declare it themselves.
149
+ * to devtools on `island:mount` ('react', 'vue', 'svelte', …). When
150
+ * omitted (and devtools is on), the driver asks the worker: package
151
+ * adapters report their renderer, imperative apps report none and the
152
+ * map draws them as plain workers. An explicit tag always wins.
129
153
  */
130
154
  framework?: string;
131
155
  /**
@@ -155,6 +179,11 @@ export interface IslandHandle {
155
179
  readonly app: string;
156
180
  /** The worker-side instance's random id — proof each island is a distinct worker. */
157
181
  readonly pid: string;
182
+ /**
183
+ * The wire instance key — `app@N` for a top-level island, `parent~app@N`
184
+ * when mounted into a proxy element inside a worker island.
185
+ */
186
+ readonly instance: string;
158
187
  readonly mode: Mode;
159
188
  opsApplied: number;
160
189
  flushCalls: number;
@@ -247,6 +276,37 @@ function buildEventPayload(
247
276
  };
248
277
  }
249
278
 
279
+ /**
280
+ * User Timing entry for one main-thread op replay (devtools only). The
281
+ * `detail.devtools` track-entry extension puts it on an 'atoll › islands'
282
+ * track in Chrome's Performance panel; other engines keep a plain measure.
283
+ * Same shape as core's task measures (`src/userTiming.ts`), including the
284
+ * immediate clear: per-batch entries would otherwise grow the buffer.
285
+ */
286
+ const measureReplay = (instance: string, via: string, count: number, start: number, end: number): void => {
287
+ if (typeof performance?.measure !== 'function') return;
288
+ const name = `atoll replay ${instance}`;
289
+ try {
290
+ performance.measure(name, {
291
+ start,
292
+ end,
293
+ detail: {
294
+ devtools: {
295
+ dataType: 'track-entry',
296
+ trackGroup: 'atoll',
297
+ track: 'islands',
298
+ color: 'primary',
299
+ tooltipText: `${instance} · ${via} · ${count} ops`,
300
+ properties: [['via', via], ['ops', count]],
301
+ },
302
+ },
303
+ });
304
+ performance.clearMeasures?.(name);
305
+ } catch {
306
+ /* no User Timing L3 — skip */
307
+ }
308
+ };
309
+
250
310
  /** Per-island instance counter — see the instance key note in mountIsland. */
251
311
  let islandSeq = 0;
252
312
 
@@ -296,7 +356,7 @@ const uncloneablePath = (value: unknown, path: string): string => {
296
356
  return path;
297
357
  };
298
358
 
299
- const assertCloneableProps = (props: Record<string, unknown>, context: string): void => {
359
+ export const assertCloneableProps = (props: Record<string, unknown>, context: string): void => {
300
360
  if (canClone(props)) return;
301
361
  const path = uncloneablePath(props, '');
302
362
  throw new Error(
@@ -327,8 +387,10 @@ const UNITLESS_CSS = new Set([
327
387
  ]);
328
388
 
329
389
  /** Coerce a wire style value to a CSS string — px-suffixes bare numbers
330
- * on non-unitless keys (custom properties pass verbatim). */
331
- const cssStyleValue = (key: string, value: unknown): string => {
390
+ * on non-unitless keys (custom properties pass verbatim). Exported for the
391
+ * worker-side nested driver (worker/subIsland.ts), which applies the same
392
+ * unit semantics when replaying style values into proxy elements. */
393
+ export const cssStyleValue = (key: string, value: unknown): string => {
332
394
  if (
333
395
  typeof value === 'number' &&
334
396
  value !== 0 &&
@@ -340,12 +402,48 @@ const cssStyleValue = (key: string, value: unknown): string => {
340
402
  return String(value);
341
403
  };
342
404
 
405
+ /**
406
+ * The worker-side mounter — registered by `@atolljs/islands/worker`
407
+ * (`worker/subIsland.ts`) so `mountIsland` serves BOTH mount targets with
408
+ * one signature: a proxy `el` (inside a worker-rendered island) takes the
409
+ * nested path, which replays the sub-worker's ops into the parent
410
+ * instance's shadow tree. Kept behind a registration slot rather than an
411
+ * import so the main-thread bundle never loads the proxy DOM.
412
+ */
413
+ let subMounter: ((opts: MountIslandOptions) => Promise<IslandHandle>) | null = null;
414
+ export const registerSubIslandMounter = (
415
+ fn: (opts: MountIslandOptions) => Promise<IslandHandle>,
416
+ ): void => {
417
+ subMounter = fn;
418
+ };
419
+
420
+ /**
421
+ * Structural discriminant — a proxy element's `doc.instance` is the parent
422
+ * instance key, a real element has no `doc` property at all. Type beats
423
+ * environment sniffing: in-process harnesses run worker and shell in one
424
+ * module graph where globals lie, but the mount target never does.
425
+ */
426
+ const isProxyMountTarget = (el: unknown): el is ProxyElement =>
427
+ typeof (el as { doc?: { instance?: unknown } } | null)?.doc?.instance === 'string';
428
+
343
429
  export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandle> {
344
430
  const {
345
- el, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
431
+ el: target, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
346
432
  client: givenClient, worker, app: givenApp, props: givenProps, framework,
347
433
  ...workerConfig
348
434
  } = opts;
435
+ if (isProxyMountTarget(target)) {
436
+ if (subMounter === null) {
437
+ throw new Error(
438
+ 'mountIsland: a proxy-element mount target means a nested island — ' +
439
+ 'the worker entry (`@atolljs/islands/worker`) must be loaded to register the sub-mounter.',
440
+ );
441
+ }
442
+ return subMounter(opts);
443
+ }
444
+ // Real DOM from here on (the proxy path delegated above) — the declared
445
+ // type keeps closures honest; the guard did the proving.
446
+ const el = target as HTMLElement;
349
447
  // Shell→worker callables: callbackProp() markers marshal to {__cb:id}
350
448
  // wire handles; the island's table keeps the real functions and the
351
449
  // driver's emit-case routes worker-side invocations back here.
@@ -356,6 +454,8 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
356
454
  return cbSeq;
357
455
  };
358
456
  const props = marshalCallbackProps(givenProps ?? {}, registerCb) as Record<string, unknown>;
457
+ /** The props as the caller last gave them — devtools previews/commands read these. */
458
+ let currentProps: Record<string, unknown> = givenProps ?? {};
359
459
  if (givenClient === undefined && worker === undefined) {
360
460
  throw new Error(
361
461
  'mountIsland: pass `worker` (a `() => new Worker(...)`/URL entry — this call builds the ' +
@@ -365,6 +465,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
365
465
  const client: IslandClient =
366
466
  givenClient ??
367
467
  connectIslandWorker({
468
+ name: givenApp,
368
469
  ...workerConfig,
369
470
  worker,
370
471
  // Poll-mode shorthand islands don't need SharedArrayBuffer at all —
@@ -777,7 +878,9 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
777
878
  break;
778
879
  }
779
880
  // Not a DOM mutation — the island→shell channel.
780
- emitDevtools({ type: 'island:event', instance, name: op.name });
881
+ if (devtoolsEnabled()) {
882
+ emitDevtools({ type: 'island:event', instance, name: op.name, payload: previewValue(op.payload, 1024) });
883
+ }
781
884
  onEvent?.(op.name, op.payload);
782
885
  break;
783
886
  }
@@ -789,8 +892,15 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
789
892
  onActivity?.();
790
893
  const t0 = performance.now();
791
894
  for (const op of ops) applyOp(op);
792
- const replayMs = performance.now() - t0;
793
- emitDevtools({ type: 'island:ops', instance, via, count: ops.length, replayMs });
895
+ const t1 = performance.now();
896
+ const replayMs = t1 - t0;
897
+ if (devtoolsEnabled()) {
898
+ emitDevtools({
899
+ type: 'island:ops', instance, via, count: ops.length, replayMs,
900
+ bytes: estimateCloneBytes(ops),
901
+ });
902
+ measureReplay(instance, via, ops.length, t0, t1);
903
+ }
794
904
  onOps?.(ops, replayMs);
795
905
  }
796
906
 
@@ -879,7 +989,12 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
879
989
  try {
880
990
  applyOps(await raced(islandCall('mount', client.mount(instance, props))), 'mount');
881
991
  pid = await raced(islandCall('whoami', client.whoami(instance)));
882
- emitDevtools({ type: 'island:mount', instance, app, pid, poolId: client.pool?.poolId, framework });
992
+ if (devtoolsEnabled()) {
993
+ // an explicit tag wins; otherwise ask the worker which renderer the app uses
994
+ const fw = framework ?? (await client.renderer(instance).catch(() => null)) ?? undefined;
995
+ emitDevtools({ type: 'island:mount', instance, app, pid, poolId: client.pool?.poolId, framework: fw });
996
+ emitDevtools({ type: 'island:props', instance, props: propsPreview(currentProps) });
997
+ }
883
998
  } catch (err) {
884
999
  // The instance may exist worker-side with ops we never applied — release it.
885
1000
  // An island-owned client also terminates (stops a crash→respawn loop on
@@ -941,6 +1056,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
941
1056
  const handle: IslandHandle = {
942
1057
  app,
943
1058
  pid,
1059
+ instance,
944
1060
  get mode() {
945
1061
  return mode;
946
1062
  },
@@ -955,12 +1071,16 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
955
1071
  const marshalled = marshalCallbackProps(next, registerCb) as Record<string, unknown>;
956
1072
  assertCloneableProps(marshalled, `updateProps(${app})`);
957
1073
  applyOps(await islandCall('updateProps', client.updateProps(instance, marshalled)), 'updateProps');
1074
+ currentProps = next;
1075
+ if (devtoolsEnabled()) emitDevtools({ type: 'island:props', instance, props: propsPreview(next) });
958
1076
  },
959
1077
  flush: async () => {
960
1078
  applyOps(await islandCall('flush', client.flush(instance)), 'flush');
961
1079
  },
962
1080
  destroy: () => {
963
1081
  destroyed = true;
1082
+ untrackDevtools?.();
1083
+ untrackDevtools = null;
964
1084
  emitDevtools({ type: 'island:unmount', instance });
965
1085
  unsubscribe?.();
966
1086
  resizeObserver?.disconnect();
@@ -992,5 +1112,9 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
992
1112
  }
993
1113
  },
994
1114
  };
1115
+ // Dashboard commands (`island.*`) — registered only while devtools is on.
1116
+ let untrackDevtools: (() => void) | null = devtoolsEnabled()
1117
+ ? trackIslandForDevtools({ handle, el, props: () => currentProps })
1118
+ : null;
995
1119
  return handle;
996
1120
  }
package/src/ops.ts CHANGED
@@ -212,4 +212,10 @@ export type IslandWorkerMethods = {
212
212
  unmount(instance: string): Op[];
213
213
  /** The mounted instance's random id — the island's "worker pid" badge. */
214
214
  whoami(instance: string): string;
215
+ /**
216
+ * The instance's renderer tag ('react', 'vue', …) from its adapter; null
217
+ * for imperative apps. Read by the driver only for devtools, and only
218
+ * when the mount declared no `framework`.
219
+ */
220
+ renderer(instance: string): string | null;
215
221
  };
@@ -153,6 +153,13 @@ export interface RenderedHandle {
153
153
  */
154
154
  export interface RenderedIslandApp {
155
155
  mount(ctx: RenderContext): RenderedHandle | void;
156
+ /**
157
+ * Renderer tag ('react', 'vue', 'svelte', 'solid', 'angular'): set by the
158
+ * package adapters, reported by the `renderer` task so devtools can mark
159
+ * an island whose mount declared no `framework`. Hand-written adapters
160
+ * may set it; imperative apps have none.
161
+ */
162
+ renderer?: string;
156
163
  }
157
164
  /** The apps an island can mount — keyed by the name the shell passes to mount(). */
158
165
  export type IslandApp = ImperativeIslandApp | RenderedIslandApp;
@@ -266,10 +273,13 @@ const resolveApp = (name: string): IslandApp | undefined =>
266
273
 
267
274
  const newPid = (): string => `w-${Math.random().toString(36).slice(2, 8)}`;
268
275
 
269
- /** 'controls' or 'data-table@7' → 'data-table' — registry name part of a instance key. */
276
+ /** 'controls' or 'data-table@7' → 'data-table' — registry name part of a instance key.
277
+ * Nested keys ('host@1~counter@1', one '~' per level) resolve the last segment. */
270
278
  const appNameOf = (instance: string): string => {
271
279
  const at = instance.lastIndexOf('@');
272
- return at === -1 ? instance : instance.slice(0, at);
280
+ const name = at === -1 ? instance : instance.slice(0, at);
281
+ const tilde = name.lastIndexOf('~');
282
+ return tilde === -1 ? name : name.slice(tilde + 1);
273
283
  };
274
284
 
275
285
  /**
@@ -617,6 +627,12 @@ function createIslandRuntime(
617
627
  whoami(instance: string): string {
618
628
  return mounts.get(instance)?.pid ?? 'unmounted';
619
629
  },
630
+
631
+ /** The instance's renderer tag (an adapter's `renderer`); null for imperative or unknown. */
632
+ renderer(instance: string): string | null {
633
+ const mounted = mounts.get(instance);
634
+ return (mounted && isRenderedInstance(mounted) && mounted.rendered.app.renderer) || null;
635
+ },
620
636
  },
621
637
  });
622
638
  }
@@ -47,6 +47,7 @@ export {
47
47
  instances,
48
48
  newElement,
49
49
  newText,
50
+ proxyInstanceStats,
50
51
  pushOp,
51
52
  registerHandler,
52
53
  ROOT_CONTAINER,
@@ -85,3 +86,19 @@ export type { EventPayload, IslandWorkerMethods, Op, WireProps } from '../ops';
85
86
  export { isEventRef } from '../ops';
86
87
  export { renderMemory } from '../memory';
87
88
  export type { DoorbellSpec } from '../memory';
89
+
90
+ export { mountSubIsland } from './subIsland';
91
+ export type { MountSubIslandOptions, SubIslandHandle } from './subIsland';
92
+
93
+ // Nested-island clients: `connectIslandWorker` is thread-agnostic — a worker
94
+ // shell calls it to spawn a sub-worker. `mountIsland` is the ONE surface:
95
+ // a proxy `el` (inside a worker-rendered tree) delegates to the registered
96
+ // sub-mounter in ./subIsland; a real `el` drives the main-thread applier.
97
+ export { connectIslandWorker, mountIsland } from '../island';
98
+ export type {
99
+ ConnectIslandWorkerConfig,
100
+ IslandClient,
101
+ IslandHandle,
102
+ Mode,
103
+ MountIslandOptions,
104
+ } from '../island';
@@ -237,10 +237,14 @@ export const proxyInstanceStats = (): {
237
237
  liveNodes: number;
238
238
  handlers: number;
239
239
  queuedOps: number;
240
+ queuedByInstance: Record<string, number>;
240
241
  } => ({
241
242
  liveNodes: instances.size,
242
243
  handlers: handlers.size,
243
244
  queuedOps: [...opsByInstance.values()].reduce((a, q) => a + q.length, 0),
245
+ queuedByInstance: Object.fromEntries(
246
+ [...opsByInstance.entries()].map(([k, q]) => [k, q.length]),
247
+ ),
244
248
  });
245
249
 
246
250
  export const getHandler = (id: number): HandlerEntry | undefined => handlers.get(id);