@atolljs/islands 0.1.6 → 0.1.8
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/README.md +3 -2
- package/package.json +2 -2
- package/src/callbackProps.ts +4 -0
- package/src/devtoolsCommands.ts +258 -0
- package/src/devtoolsPreview.ts +46 -0
- package/src/island.ts +203 -43
- package/src/ops.ts +6 -0
- package/src/worker/defineWorkers.ts +18 -2
- package/src/worker/index.ts +17 -0
- package/src/worker/instance.ts +4 -0
- package/src/worker/subIsland.ts +619 -0
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
13
|
-
* worker's memory. A
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 {
|
|
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
|
|
53
|
+
/** One island client: one dedicated worker, one doorbell buffer. */
|
|
41
54
|
export type IslandClient = WorkerClient<IslandWorkerDefinition, DoorbellSpec>;
|
|
42
55
|
|
|
43
56
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `poolSize`/`worker`/`sharedMemory` (all island-internal) and the
|
|
47
|
-
* below pins `
|
|
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
|
-
* (
|
|
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
|
|
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
|
|
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
|
-
|
|
97
|
+
workers: 1,
|
|
84
98
|
});
|
|
85
99
|
|
|
86
100
|
interface MountIslandBaseOptions {
|
|
87
|
-
/**
|
|
88
|
-
|
|
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. */
|
|
@@ -121,6 +144,14 @@ interface MountIslandBaseOptions {
|
|
|
121
144
|
* cross-origin isolation requirement. Switch later with `handle.setMode`.
|
|
122
145
|
*/
|
|
123
146
|
mode?: Mode;
|
|
147
|
+
/**
|
|
148
|
+
* Optional renderer/framework tag — pure observability metadata forwarded
|
|
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.
|
|
153
|
+
*/
|
|
154
|
+
framework?: string;
|
|
124
155
|
/**
|
|
125
156
|
* Milliseconds the mount handshake (mount + whoami) may take before the
|
|
126
157
|
* returned promise rejects — default 15_000, `0` disables. A worker entry
|
|
@@ -148,6 +179,11 @@ export interface IslandHandle {
|
|
|
148
179
|
readonly app: string;
|
|
149
180
|
/** The worker-side instance's random id — proof each island is a distinct worker. */
|
|
150
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;
|
|
151
187
|
readonly mode: Mode;
|
|
152
188
|
opsApplied: number;
|
|
153
189
|
flushCalls: number;
|
|
@@ -240,6 +276,37 @@ function buildEventPayload(
|
|
|
240
276
|
};
|
|
241
277
|
}
|
|
242
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
|
+
|
|
243
310
|
/** Per-island instance counter — see the instance key note in mountIsland. */
|
|
244
311
|
let islandSeq = 0;
|
|
245
312
|
|
|
@@ -289,7 +356,7 @@ const uncloneablePath = (value: unknown, path: string): string => {
|
|
|
289
356
|
return path;
|
|
290
357
|
};
|
|
291
358
|
|
|
292
|
-
const assertCloneableProps = (props: Record<string, unknown>, context: string): void => {
|
|
359
|
+
export const assertCloneableProps = (props: Record<string, unknown>, context: string): void => {
|
|
293
360
|
if (canClone(props)) return;
|
|
294
361
|
const path = uncloneablePath(props, '');
|
|
295
362
|
throw new Error(
|
|
@@ -320,8 +387,10 @@ const UNITLESS_CSS = new Set([
|
|
|
320
387
|
]);
|
|
321
388
|
|
|
322
389
|
/** Coerce a wire style value to a CSS string — px-suffixes bare numbers
|
|
323
|
-
* on non-unitless keys (custom properties pass verbatim).
|
|
324
|
-
|
|
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 => {
|
|
325
394
|
if (
|
|
326
395
|
typeof value === 'number' &&
|
|
327
396
|
value !== 0 &&
|
|
@@ -333,12 +402,48 @@ const cssStyleValue = (key: string, value: unknown): string => {
|
|
|
333
402
|
return String(value);
|
|
334
403
|
};
|
|
335
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
|
+
|
|
336
429
|
export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandle> {
|
|
337
430
|
const {
|
|
338
|
-
el, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
|
|
339
|
-
client: givenClient, worker, app: givenApp, props: givenProps,
|
|
431
|
+
el: target, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
|
|
432
|
+
client: givenClient, worker, app: givenApp, props: givenProps, framework,
|
|
340
433
|
...workerConfig
|
|
341
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;
|
|
342
447
|
// Shell→worker callables: callbackProp() markers marshal to {__cb:id}
|
|
343
448
|
// wire handles; the island's table keeps the real functions and the
|
|
344
449
|
// driver's emit-case routes worker-side invocations back here.
|
|
@@ -349,6 +454,8 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
349
454
|
return cbSeq;
|
|
350
455
|
};
|
|
351
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 ?? {};
|
|
352
459
|
if (givenClient === undefined && worker === undefined) {
|
|
353
460
|
throw new Error(
|
|
354
461
|
'mountIsland: pass `worker` (a `() => new Worker(...)`/URL entry — this call builds the ' +
|
|
@@ -358,6 +465,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
358
465
|
const client: IslandClient =
|
|
359
466
|
givenClient ??
|
|
360
467
|
connectIslandWorker({
|
|
468
|
+
name: givenApp,
|
|
361
469
|
...workerConfig,
|
|
362
470
|
worker,
|
|
363
471
|
// Poll-mode shorthand islands don't need SharedArrayBuffer at all —
|
|
@@ -442,9 +550,8 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
442
550
|
);
|
|
443
551
|
// The whole point: an event = one postMessage round-trip. The worker
|
|
444
552
|
// re-renders, we apply whatever ops come back.
|
|
445
|
-
void client
|
|
446
|
-
.
|
|
447
|
-
.then(applyOps)
|
|
553
|
+
void islandCall('dispatch', client.dispatch(handlerId, payload))
|
|
554
|
+
.then((ops) => applyOps(ops, 'dispatch'))
|
|
448
555
|
.catch((err) => console.error(`[island ${instance}] dispatch failed`, err));
|
|
449
556
|
};
|
|
450
557
|
}
|
|
@@ -771,24 +878,62 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
771
878
|
break;
|
|
772
879
|
}
|
|
773
880
|
// Not a DOM mutation — the island→shell channel.
|
|
881
|
+
if (devtoolsEnabled()) {
|
|
882
|
+
emitDevtools({ type: 'island:event', instance, name: op.name, payload: previewValue(op.payload, 1024) });
|
|
883
|
+
}
|
|
774
884
|
onEvent?.(op.name, op.payload);
|
|
775
885
|
break;
|
|
776
886
|
}
|
|
777
887
|
}
|
|
778
888
|
}
|
|
779
889
|
|
|
780
|
-
function applyOps(ops: Op[]): void {
|
|
890
|
+
function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'flush'): void {
|
|
781
891
|
opsApplied += ops.length;
|
|
782
892
|
onActivity?.();
|
|
783
|
-
const t0 =
|
|
893
|
+
const t0 = performance.now();
|
|
784
894
|
for (const op of ops) applyOp(op);
|
|
785
|
-
|
|
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
|
+
}
|
|
904
|
+
onOps?.(ops, replayMs);
|
|
786
905
|
}
|
|
787
906
|
|
|
907
|
+
/** Times a main→worker round-trip for the devtools inspector. */
|
|
908
|
+
const islandCall = <T>(
|
|
909
|
+
method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount',
|
|
910
|
+
p: Promise<T>,
|
|
911
|
+
): Promise<T> => {
|
|
912
|
+
const t0 = performance.now();
|
|
913
|
+
return p.then(
|
|
914
|
+
(r) => {
|
|
915
|
+
emitDevtools({
|
|
916
|
+
type: 'island:task', instance, method,
|
|
917
|
+
ms: performance.now() - t0,
|
|
918
|
+
ops: Array.isArray(r) ? r.length : undefined,
|
|
919
|
+
});
|
|
920
|
+
return r;
|
|
921
|
+
},
|
|
922
|
+
(err) => {
|
|
923
|
+
emitDevtools({
|
|
924
|
+
type: 'island:task', instance, method,
|
|
925
|
+
ms: performance.now() - t0,
|
|
926
|
+
error: err instanceof Error ? err.message : String(err),
|
|
927
|
+
});
|
|
928
|
+
throw err;
|
|
929
|
+
},
|
|
930
|
+
);
|
|
931
|
+
};
|
|
932
|
+
|
|
788
933
|
const doFlush = (): void => {
|
|
789
934
|
if (destroyed) return;
|
|
790
935
|
flushCalls++;
|
|
791
|
-
void client.flush(instance).then(applyOps);
|
|
936
|
+
void islandCall('flush', client.flush(instance)).then((ops) => applyOps(ops, 'flush'));
|
|
792
937
|
};
|
|
793
938
|
|
|
794
939
|
/* ── Transport: push (shared-memory doorbell) vs poll ─────────────────── */
|
|
@@ -842,15 +987,21 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
842
987
|
|
|
843
988
|
let pid: string;
|
|
844
989
|
try {
|
|
845
|
-
applyOps(await raced(client.mount(instance, props)));
|
|
846
|
-
pid = await raced(client.whoami(instance));
|
|
990
|
+
applyOps(await raced(islandCall('mount', client.mount(instance, props))), 'mount');
|
|
991
|
+
pid = await raced(islandCall('whoami', client.whoami(instance)));
|
|
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
|
+
}
|
|
847
998
|
} catch (err) {
|
|
848
999
|
// The instance may exist worker-side with ops we never applied — release it.
|
|
849
1000
|
// An island-owned client also terminates (stops a crash→respawn loop on
|
|
850
1001
|
// a permanently-broken entry); a shared client just drops the instance.
|
|
851
1002
|
clientMounts.set(client, (clientMounts.get(client) ?? 1) - 1);
|
|
852
1003
|
if (givenClient === undefined) client.terminate();
|
|
853
|
-
else void client.unmount(instance).catch(() => {});
|
|
1004
|
+
else void islandCall('unmount', client.unmount(instance)).catch(() => {});
|
|
854
1005
|
throw err;
|
|
855
1006
|
}
|
|
856
1007
|
|
|
@@ -879,7 +1030,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
879
1030
|
if (w === lastW && h === lastH) return;
|
|
880
1031
|
lastW = w;
|
|
881
1032
|
lastH = h;
|
|
882
|
-
void client.setSize(instance, w, h).then(applyOps);
|
|
1033
|
+
void islandCall('setSize', client.setSize(instance, w, h)).then((ops) => applyOps(ops, 'setSize'));
|
|
883
1034
|
};
|
|
884
1035
|
const resizeObserver =
|
|
885
1036
|
typeof ResizeObserver === 'function'
|
|
@@ -905,6 +1056,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
905
1056
|
const handle: IslandHandle = {
|
|
906
1057
|
app,
|
|
907
1058
|
pid,
|
|
1059
|
+
instance,
|
|
908
1060
|
get mode() {
|
|
909
1061
|
return mode;
|
|
910
1062
|
},
|
|
@@ -918,13 +1070,18 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
918
1070
|
updateProps: async (next: Record<string, unknown>) => {
|
|
919
1071
|
const marshalled = marshalCallbackProps(next, registerCb) as Record<string, unknown>;
|
|
920
1072
|
assertCloneableProps(marshalled, `updateProps(${app})`);
|
|
921
|
-
applyOps(await client.updateProps(instance, marshalled));
|
|
1073
|
+
applyOps(await islandCall('updateProps', client.updateProps(instance, marshalled)), 'updateProps');
|
|
1074
|
+
currentProps = next;
|
|
1075
|
+
if (devtoolsEnabled()) emitDevtools({ type: 'island:props', instance, props: propsPreview(next) });
|
|
922
1076
|
},
|
|
923
1077
|
flush: async () => {
|
|
924
|
-
applyOps(await client.flush(instance));
|
|
1078
|
+
applyOps(await islandCall('flush', client.flush(instance)), 'flush');
|
|
925
1079
|
},
|
|
926
1080
|
destroy: () => {
|
|
927
1081
|
destroyed = true;
|
|
1082
|
+
untrackDevtools?.();
|
|
1083
|
+
untrackDevtools = null;
|
|
1084
|
+
emitDevtools({ type: 'island:unmount', instance });
|
|
928
1085
|
unsubscribe?.();
|
|
929
1086
|
resizeObserver?.disconnect();
|
|
930
1087
|
if (pollTimer !== null) clearInterval(pollTimer);
|
|
@@ -936,7 +1093,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
936
1093
|
// detach ops are irrelevant if our element is already gone, so the
|
|
937
1094
|
// return batch is dropped. Best-effort: the instance map and its
|
|
938
1095
|
// handlers release regardless.
|
|
939
|
-
void client.unmount(instance).catch(() => {});
|
|
1096
|
+
void islandCall('unmount', client.unmount(instance)).catch(() => {});
|
|
940
1097
|
} else {
|
|
941
1098
|
// Last island — still run the instance's teardown first so the app's
|
|
942
1099
|
// dispose hook cancels deferred work (library timers, animation
|
|
@@ -944,8 +1101,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
944
1101
|
// kill the thread before unmount lands — equally fine, process
|
|
945
1102
|
// death is teardown — but in-process workers need this or the
|
|
946
1103
|
// instance zombies on and its pending callbacks mutate foreign docs.
|
|
947
|
-
void client
|
|
948
|
-
.unmount(instance)
|
|
1104
|
+
void islandCall('unmount', client.unmount(instance))
|
|
949
1105
|
.catch(() => {})
|
|
950
1106
|
.finally(() => {
|
|
951
1107
|
// A remount may have registered while teardown was in flight
|
|
@@ -956,5 +1112,9 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
956
1112
|
}
|
|
957
1113
|
},
|
|
958
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;
|
|
959
1119
|
return handle;
|
|
960
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
|
-
|
|
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
|
}
|
package/src/worker/index.ts
CHANGED
|
@@ -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';
|
package/src/worker/instance.ts
CHANGED
|
@@ -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);
|