@atolljs/islands 0.1.0
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 +346 -0
- package/package.json +29 -0
- package/src/app.ts +81 -0
- package/src/callbackProps.ts +75 -0
- package/src/index.ts +40 -0
- package/src/island.ts +905 -0
- package/src/memory.ts +30 -0
- package/src/ops.ts +215 -0
- package/src/react-reconciler.d.ts +18 -0
- package/src/worker/defineWorkers.ts +666 -0
- package/src/worker/dom/css.ts +152 -0
- package/src/worker/dom/document.ts +589 -0
- package/src/worker/dom/element.ts +656 -0
- package/src/worker/dom/html.ts +107 -0
- package/src/worker/dom/node.ts +481 -0
- package/src/worker/dom/selectors.ts +108 -0
- package/src/worker/dom/window.ts +528 -0
- package/src/worker/hostConfig.ts +446 -0
- package/src/worker/index.ts +78 -0
- package/src/worker/instance.ts +317 -0
- package/src/worker/proxyDom.ts +28 -0
- package/src/worker/reactInstance.ts +74 -0
- package/src/worker/slot.tsx +18 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two entry points, two topologies:
|
|
3
|
+
*
|
|
4
|
+
* // render.worker.ts — a REGISTRY worker: one script, many apps
|
|
5
|
+
* import { definePolyWorker } from '@atolljs/islands/worker';
|
|
6
|
+
* export const renderWorker = definePolyWorker({
|
|
7
|
+
* apps: { controls: ControlsApp, table: TableApp },
|
|
8
|
+
* });
|
|
9
|
+
*
|
|
10
|
+
* // charts.worker.ts — a MONO worker: one script, one app (1:1)
|
|
11
|
+
* export const chartsWorker = defineMonoWorker(ChartsApp);
|
|
12
|
+
*
|
|
13
|
+
* `definePolyWorker` serves a registry — every island's worker runs the
|
|
14
|
+
* same script and `mount(instance, props)` picks a component out of `apps`.
|
|
15
|
+
* `defineMonoWorker` serves exactly one app: the shell mounts it without
|
|
16
|
+
* naming a registry key (or with any name — a single-registered-app worker
|
|
17
|
+
* resolves its sole app regardless), and the worker's bundle carries only
|
|
18
|
+
* that app's dependencies.
|
|
19
|
+
*
|
|
20
|
+
* APP REGISTRY is module-level: in a real worker each entry file loads its
|
|
21
|
+
* own module graph (a instance worker sees exactly its app); under the
|
|
22
|
+
* in-process test harness several worker entries share one graph, so
|
|
23
|
+
* registration is a UNION and mounts still resolve — same semantics both
|
|
24
|
+
* worlds.
|
|
25
|
+
*
|
|
26
|
+
* INSTANCE KEYS are `app` or `app@instance` — the part before the last '@'
|
|
27
|
+
* names the registry app (or 'main' for unnamed instance-worker mounts), the
|
|
28
|
+
* rest distinguishes instances so the same app can mount more than once —
|
|
29
|
+
* INCLUDING into one shared worker when two islands share a client
|
|
30
|
+
* (multi-island-per-worker: one worker, two mounts, two reconcilers).
|
|
31
|
+
* MountIsland mints a fresh key per island. Wire signatures carry the instance key (`updateProps(instance,
|
|
32
|
+
* props)`, `flush(instance)`, `whoami(instance)`) so a call routes to its instance —
|
|
33
|
+
* the main-thread mountIsland helper binds it, so shell code just calls
|
|
34
|
+
* `island.updateProps(props)`.
|
|
35
|
+
*
|
|
36
|
+
* IMPERATIVE INSTANCES: a registry entry can be `{ imperative: (doc, props) }`
|
|
37
|
+
* instead of a component. Those mounts hold no reconciler at all: mount
|
|
38
|
+
* hands the app a instance-scoped proxy DOM and its mutations emit ops
|
|
39
|
+
* directly (updateProps = clear + rebuild, dispatch = runInInstance + drain,
|
|
40
|
+
* flush = drain only). See worker/proxyDom.ts.
|
|
41
|
+
*
|
|
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.
|
|
47
|
+
*
|
|
48
|
+
* Ops still ride back through the pool's ordinary postMessage channel — the
|
|
49
|
+
* sharedMemory contract here is only the doorbell (see memory.ts): a commit
|
|
50
|
+
* counter the main thread observe()s to trigger flush() as a push. Drop it
|
|
51
|
+
* entirely and the islands still work on the 50ms poll — that's the
|
|
52
|
+
* message-only mode.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import type { ReactElement } from 'react';
|
|
56
|
+
import type { ReactInstance } from './reactInstance';
|
|
57
|
+
import { defineWorker } from '@atolljs/core';
|
|
58
|
+
import type { SharedMemory, WorkerDefinition } from '@atolljs/core';
|
|
59
|
+
import { islandAppNameOf } from '../app';
|
|
60
|
+
import { renderMemory, type DoorbellSpec } from '../memory';
|
|
61
|
+
import { CALLBACK_EVENT, unmarshalCallbackProps } from '../callbackProps';
|
|
62
|
+
import {
|
|
63
|
+
createProxyDocument,
|
|
64
|
+
installInstanceDispatcher,
|
|
65
|
+
docForInstance,
|
|
66
|
+
type InternalDocument,
|
|
67
|
+
type ProxyDocument,
|
|
68
|
+
} from './proxyDom';
|
|
69
|
+
import {
|
|
70
|
+
bumpOpsVersion,
|
|
71
|
+
getHandler,
|
|
72
|
+
instances,
|
|
73
|
+
pushOp,
|
|
74
|
+
runInInstance,
|
|
75
|
+
setActiveInstance,
|
|
76
|
+
setDoorbellContract,
|
|
77
|
+
setInstanceSize,
|
|
78
|
+
takeOps,
|
|
79
|
+
} from './instance';
|
|
80
|
+
import type { EventPayload, IslandWorkerMethods, Op } from '../ops';
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 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.
|
|
89
|
+
*/
|
|
90
|
+
export type ReactIslandApp = (props: any) => ReactElement;
|
|
91
|
+
export interface ImperativeIslandApp {
|
|
92
|
+
imperative: (doc: ProxyDocument, props: Record<string, unknown>) => void;
|
|
93
|
+
/**
|
|
94
|
+
* Optional teardown — runs inside the instance's scope BEFORE its proxy
|
|
95
|
+
* document is disposed (unmount, remount, updateProps rebuild). Cancel
|
|
96
|
+
* library timers/animation loops/listeners here (e.g. `map.remove()`) so
|
|
97
|
+
* deferred work can't mutate a dead instance or a foreign ambient document
|
|
98
|
+
* after teardown.
|
|
99
|
+
*/
|
|
100
|
+
dispose?: (doc: ProxyDocument) => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What a non-React framework renderer receives at mount: the instance's wire
|
|
105
|
+
* key (scopes emit() and instance-less ops), a fresh proxy document whose
|
|
106
|
+
* mutations emit ops, and the serialized props the shell mounted with.
|
|
107
|
+
*/
|
|
108
|
+
export interface RenderContext {
|
|
109
|
+
instance: string;
|
|
110
|
+
doc: ProxyDocument;
|
|
111
|
+
props: Record<string, unknown>;
|
|
112
|
+
}
|
|
113
|
+
export interface RenderedHandle {
|
|
114
|
+
/**
|
|
115
|
+
* Fine-grained prop update — the renderer patches its live tree. When
|
|
116
|
+
* omitted, updateProps falls back to imperative semantics: dispose +
|
|
117
|
+
* clear + re-mount on a fresh document.
|
|
118
|
+
*/
|
|
119
|
+
update?(props: Record<string, unknown>): void;
|
|
120
|
+
/**
|
|
121
|
+
* App teardown — runs inside the instance's scope BEFORE its proxy document
|
|
122
|
+
* is disposed (unmount, remount, updateProps rebuild). Same rules as
|
|
123
|
+
* ImperativeIslandApp.dispose: cancel framework roots/effects here.
|
|
124
|
+
*/
|
|
125
|
+
dispose?(): void;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A renderer-backed app: a non-React framework renderer (Vue, Svelte,
|
|
129
|
+
* Solid, Angular) whose `mount` renders component output into the instance's
|
|
130
|
+
* 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.
|
|
134
|
+
*/
|
|
135
|
+
export interface RenderedIslandApp {
|
|
136
|
+
mount(ctx: RenderContext): RenderedHandle | void;
|
|
137
|
+
}
|
|
138
|
+
/** The apps an island can mount — keyed by the name the shell passes to mount(). */
|
|
139
|
+
export type IslandApp = ReactIslandApp | ImperativeIslandApp | RenderedIslandApp;
|
|
140
|
+
|
|
141
|
+
export interface PolyWorkerRegistry {
|
|
142
|
+
/** Name → island app registry. The shell's `mountIsland({ app: name })`
|
|
143
|
+
* picks one per island. */
|
|
144
|
+
apps: Record<string, IslandApp>;
|
|
145
|
+
/**
|
|
146
|
+
* The doorbell contract the worker exposes — defaults to the package's
|
|
147
|
+
* `renderMemory`. Rarely needed: pass a different doorbell-SPEC instance
|
|
148
|
+
* if your worker module already defines one (the main side always uses
|
|
149
|
+
* `makeDoorbell()`, so the spec must match: `opsVersion` number field).
|
|
150
|
+
*/
|
|
151
|
+
sharedMemory?: SharedMemory<DoorbellSpec>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const isImperative = (app: IslandApp | undefined): app is ImperativeIslandApp =>
|
|
155
|
+
typeof app === 'object' && app !== null && 'imperative' in app;
|
|
156
|
+
|
|
157
|
+
const isRendered = (app: IslandApp | undefined): app is RenderedIslandApp =>
|
|
158
|
+
typeof app === 'object' && app !== null && 'mount' in app;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Wrap a `{__cb:id}` wire handle into the callable the app receives: pushing
|
|
162
|
+
* a reserved `emit` op onto THIS instance's queue (the driver's emit-case
|
|
163
|
+
* dispatches it to the marshalled shell function) and ringing the doorbell
|
|
164
|
+
* so out-of-task invocations — timers, continuations — still flush.
|
|
165
|
+
* Fire-and-forget: there is no channel for a shell return value.
|
|
166
|
+
*/
|
|
167
|
+
const callbackFactory =
|
|
168
|
+
(instance: string) =>
|
|
169
|
+
(id: number) =>
|
|
170
|
+
(...args: unknown[]): void => {
|
|
171
|
+
pushOp(instance, { t: 'emit', name: CALLBACK_EVENT, payload: { id, args } });
|
|
172
|
+
bumpOpsVersion();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
interface InstanceBase {
|
|
176
|
+
/** The wire key — 'app' or 'app@instance'; op queues route by this. */
|
|
177
|
+
key: string;
|
|
178
|
+
/** The registry name — which component this instance renders. */
|
|
179
|
+
app: string;
|
|
180
|
+
/** Per-instance random id — shown in the island's badge as proof it's a
|
|
181
|
+
* distinct render instance (in production: a distinct worker). Stable across
|
|
182
|
+
* remounts of the same instance key. */
|
|
183
|
+
pid: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface ImperativeInstance extends InstanceBase {
|
|
187
|
+
rendered?: undefined;
|
|
188
|
+
imperative: {
|
|
189
|
+
build: ImperativeIslandApp['imperative'];
|
|
190
|
+
/** Optional app teardown — run before the instance's doc is disposed. */
|
|
191
|
+
dispose: ImperativeIslandApp['dispose'];
|
|
192
|
+
/** The instance's proxy document — replaced on each rebuild. */
|
|
193
|
+
doc: ProxyDocument;
|
|
194
|
+
props: Record<string, unknown>;
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface RenderedInstance extends InstanceBase {
|
|
199
|
+
imperative?: undefined;
|
|
200
|
+
rendered: {
|
|
201
|
+
app: RenderedIslandApp;
|
|
202
|
+
/** The mount-returned handle — drives fine-grained updates/teardown. */
|
|
203
|
+
handle: RenderedHandle | void;
|
|
204
|
+
/** The instance's proxy document — replaced on each rebuild. */
|
|
205
|
+
doc: ProxyDocument;
|
|
206
|
+
props: Record<string, unknown>;
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A mounted instance — one per island worker in production. */
|
|
211
|
+
export type Instance = ReactInstance | ImperativeInstance | RenderedInstance;
|
|
212
|
+
|
|
213
|
+
const isImperativeInstance = (r: Instance): r is ImperativeInstance => r.imperative !== undefined;
|
|
214
|
+
const isRenderedInstance = (r: Instance): r is RenderedInstance => r.rendered !== undefined;
|
|
215
|
+
|
|
216
|
+
/** instance key → mounted instance. Production workers hold one entry per island
|
|
217
|
+
* mounted into them — more than one only when islands share a client. */
|
|
218
|
+
const mounts = new Map<string, Instance>();
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Module-level app registry — one module graph = one registry (see the
|
|
222
|
+
* header note). definePolyWorker registers its whole `apps` map;
|
|
223
|
+
* defineMonoWorker registers its single app.
|
|
224
|
+
*/
|
|
225
|
+
const APP_REGISTRY = new Map<string, IslandApp>();
|
|
226
|
+
|
|
227
|
+
const registerSingleApp = (name: string, app: IslandApp): void => {
|
|
228
|
+
APP_REGISTRY.set(name, app);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve a mount's registry name → app. A single-entry registry is
|
|
233
|
+
* name-blind: a instance worker mounts its one app whatever instance key arrives
|
|
234
|
+
* ('main@1' from an un-named <Island/>, 'charts@2' from a stamped one), so
|
|
235
|
+
* `app` is genuinely optional on the shell side for 1:1 workers.
|
|
236
|
+
*/
|
|
237
|
+
const resolveApp = (name: string): IslandApp | undefined =>
|
|
238
|
+
APP_REGISTRY.get(name) ??
|
|
239
|
+
(APP_REGISTRY.size === 1 ? APP_REGISTRY.values().next().value : undefined);
|
|
240
|
+
|
|
241
|
+
const newPid = (): string => `w-${Math.random().toString(36).slice(2, 8)}`;
|
|
242
|
+
|
|
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
|
+
/** 'controls' or 'data-table@7' → 'data-table' — registry name part of a instance key. */
|
|
253
|
+
const appNameOf = (instance: string): string => {
|
|
254
|
+
const at = instance.lastIndexOf('@');
|
|
255
|
+
return at === -1 ? instance : instance.slice(0, at);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The worker-side half of an island — registers mount / updateProps /
|
|
260
|
+
* dispatch / flush / whoami on the worker's task surface and wires the
|
|
261
|
+
* doorbell contract. Shared by both entry points; they differ only in what
|
|
262
|
+
* they register.
|
|
263
|
+
*/
|
|
264
|
+
function createIslandRuntime(
|
|
265
|
+
sharedMemory: SharedMemory<DoorbellSpec> = renderMemory,
|
|
266
|
+
): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
|
|
267
|
+
// Point bumpOpsVersion at the declared contract (it's the same object as
|
|
268
|
+
// renderMemory unless a custom doorbell instance was passed).
|
|
269
|
+
setDoorbellContract(sharedMemory);
|
|
270
|
+
// Give every instance DOM globals — `document`/`window`/`Element` resolve to
|
|
271
|
+
// the active instance's proxy document. Libraries a React app pulls in
|
|
272
|
+
// (recharts, d3-ish helpers) can then read them without the app ever
|
|
273
|
+
// installing a shim; imperative apps get the same globals via
|
|
274
|
+
// installDomShim, which builds on this.
|
|
275
|
+
installInstanceDispatcher();
|
|
276
|
+
|
|
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.
|
|
281
|
+
function createInstance(key: string, pid: string): Instance {
|
|
282
|
+
const app = resolveApp(appNameOf(key));
|
|
283
|
+
if (isRendered(app)) {
|
|
284
|
+
return {
|
|
285
|
+
key,
|
|
286
|
+
app: appNameOf(key),
|
|
287
|
+
pid,
|
|
288
|
+
rendered: { app, handle: undefined, doc: createProxyDocument(key), props: {} },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (isImperative(app)) {
|
|
292
|
+
return {
|
|
293
|
+
key,
|
|
294
|
+
app: appNameOf(key),
|
|
295
|
+
pid,
|
|
296
|
+
imperative: {
|
|
297
|
+
build: app.imperative,
|
|
298
|
+
dispose: app.dispose,
|
|
299
|
+
doc: createProxyDocument(key),
|
|
300
|
+
props: {},
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
throw new Error(
|
|
305
|
+
`createInstance: "${appNameOf(key)}" resolved to a React app — React mounts are created by reactInstance (lazy import), this path is unreachable`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
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
|
+
/**
|
|
332
|
+
* Rebuild an imperative instance — the simplest honest updateProps/remount
|
|
333
|
+
* semantics for code with no reconciler: dispose the old document (its
|
|
334
|
+
* handler ids die with it — and the global DOM shim it installed is
|
|
335
|
+
* unwound), emit `clear` so the driver empties the island root, then
|
|
336
|
+
* re-run build() on a FRESH proxy document whose shadow tree starts
|
|
337
|
+
* empty like the real one. All inside the instance's active scope so emit()
|
|
338
|
+
* and instance-less ops route correctly.
|
|
339
|
+
*/
|
|
340
|
+
function rebuildImperative(instance: ImperativeInstance, props: Record<string, unknown>): Op[] {
|
|
341
|
+
return runInInstance(instance.key, () => {
|
|
342
|
+
const imp = instance.imperative;
|
|
343
|
+
imp.dispose?.(imp.doc);
|
|
344
|
+
imp.doc.dispose();
|
|
345
|
+
pushOp(instance.key, { t: 'clear' });
|
|
346
|
+
imp.doc = createProxyDocument(instance.key);
|
|
347
|
+
imp.props = props;
|
|
348
|
+
imp.build(imp.doc, props);
|
|
349
|
+
return takeOps(instance.key);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Rebuild a rendered instance — dispose the framework's mount handle, drop
|
|
355
|
+
* the proxy document, `clear`, then re-mount on a fresh document. Used
|
|
356
|
+
* for remounts and for updateProps when the handle exposes no `update`.
|
|
357
|
+
*/
|
|
358
|
+
function rebuildRendered(instance: RenderedInstance, props: Record<string, unknown>): Op[] {
|
|
359
|
+
return runInInstance(instance.key, () => {
|
|
360
|
+
const r = instance.rendered;
|
|
361
|
+
r.handle?.dispose?.();
|
|
362
|
+
r.doc.dispose();
|
|
363
|
+
pushOp(instance.key, { t: 'clear' });
|
|
364
|
+
r.doc = createProxyDocument(instance.key);
|
|
365
|
+
r.props = props;
|
|
366
|
+
r.handle = r.app.mount({ instance: instance.key, doc: r.doc, props });
|
|
367
|
+
return takeOps(instance.key);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return defineWorker({
|
|
372
|
+
sharedMemory,
|
|
373
|
+
methods: {
|
|
374
|
+
/**
|
|
375
|
+
* Mount apps[appNameOf(instance)] into its own root; returns the
|
|
376
|
+
* initial op batch. The instance key may carry an instance suffix
|
|
377
|
+
* ('data-table@3') so the same app can mount multiple times.
|
|
378
|
+
*
|
|
379
|
+
* Mounting a instance key that is already mounted is a REMOUNT: the old
|
|
380
|
+
* tree is unmounted first (a `clear` op + GC of its instance records),
|
|
381
|
+
* then a fresh container renders the new tree — the returned batch
|
|
382
|
+
* replays cleanly onto an emptied root. The pid is kept across remounts.
|
|
383
|
+
*/
|
|
384
|
+
async mount(instance: string, props: Record<string, unknown> = {}): Promise<Op[]> {
|
|
385
|
+
// {__cb:id} handles become callables scoped to THIS instance — invoking
|
|
386
|
+
// one emits a callback-prop op the driver routes to the shell's
|
|
387
|
+
// marshalled function (fire-and-forget, doorbell-rung for out-of-
|
|
388
|
+
// task callers like timers/promise continuations).
|
|
389
|
+
props = unmarshalCallbackProps(props, callbackFactory(instance)) as Record<string, unknown>;
|
|
390
|
+
const App = resolveApp(appNameOf(instance));
|
|
391
|
+
if (App === undefined) {
|
|
392
|
+
throw new Error(
|
|
393
|
+
`mount: unknown app "${instance}" — registry has: ${[...APP_REGISTRY.keys()].join(', ')}`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let mounted = mounts.get(instance);
|
|
398
|
+
// Imperative/rendered remount — same clear+rebuild semantics as
|
|
399
|
+
// updateProps; the instance (and pid) survives.
|
|
400
|
+
if (mounted !== undefined && isImperativeInstance(mounted)) {
|
|
401
|
+
return rebuildImperative(mounted, props);
|
|
402
|
+
}
|
|
403
|
+
if (mounted !== undefined && isRenderedInstance(mounted)) {
|
|
404
|
+
return rebuildRendered(mounted, props);
|
|
405
|
+
}
|
|
406
|
+
|
|
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);
|
|
438
|
+
|
|
439
|
+
if (isImperativeInstance(mounted)) {
|
|
440
|
+
// First mount of an imperative instance — run build() in the instance's
|
|
441
|
+
// scope and drain the ops its proxy-DOM mutations emitted.
|
|
442
|
+
const imp = mounted.imperative;
|
|
443
|
+
imp.props = props;
|
|
444
|
+
return ops.concat(
|
|
445
|
+
runInInstance(instance, () => {
|
|
446
|
+
imp.build(imp.doc, props);
|
|
447
|
+
return takeOps(instance);
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// First mount of a rendered instance — mount() in the instance's scope;
|
|
453
|
+
// the framework's proxy-DOM mutations emit the initial op batch.
|
|
454
|
+
const r = (mounted as RenderedInstance).rendered;
|
|
455
|
+
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
|
+
);
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Re-render the instance's root with new props — the shell→island channel.
|
|
466
|
+
* `updateProps` is how the shell mediates between islands (controls
|
|
467
|
+
* emits filterChanged → shell → table.updateProps({filter})).
|
|
468
|
+
*/
|
|
469
|
+
updateProps(instance: string, props: Record<string, unknown>): Op[] {
|
|
470
|
+
props = unmarshalCallbackProps(props, callbackFactory(instance)) as Record<string, unknown>;
|
|
471
|
+
const mounted = mounts.get(instance);
|
|
472
|
+
if (mounted === undefined) {
|
|
473
|
+
throw new Error(`updateProps: "${instance}" is not mounted in this worker — mount() first`);
|
|
474
|
+
}
|
|
475
|
+
// Imperative mounts have no diffing — updateProps REBUILDS: clear the
|
|
476
|
+
// root and re-run build(props) on a fresh proxy document. Documented
|
|
477
|
+
// as the honest semantics; fine for widgets, not for huge trees.
|
|
478
|
+
if (isImperativeInstance(mounted)) return rebuildImperative(mounted, props);
|
|
479
|
+
// Rendered mounts prefer their own fine-grained update; absent a
|
|
480
|
+
// 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));
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Run the prop function the main thread identified by handlerId —
|
|
496
|
+
* `__evt` refs are handles into the worker's handler table. The entry
|
|
497
|
+
* records which instance registered it, so the re-render's ops land on the
|
|
498
|
+
* right island's queue.
|
|
499
|
+
*/
|
|
500
|
+
dispatch(handlerId: number, payload: EventPayload): Op[] {
|
|
501
|
+
const entry = getHandler(handlerId);
|
|
502
|
+
if (entry === undefined) return [];
|
|
503
|
+
const instance = mounts.get(entry.instance);
|
|
504
|
+
if (instance === undefined) return []; // stale handler — its tree was remounted
|
|
505
|
+
// Give handlers real event-object semantics: `target` is the proxy
|
|
506
|
+
// node for the wire's targetId (when it maps to an op-created node),
|
|
507
|
+
// `currentTarget` the element this handler was attached to. Libraries
|
|
508
|
+
// that read geometry off them (recharts' getRelativeCoordinate) get
|
|
509
|
+
// the proxy's honest zeros instead of crashing on undefined.
|
|
510
|
+
const p = payload as EventPayload & { target?: unknown; currentTarget?: unknown };
|
|
511
|
+
if (p.target === undefined || p.currentTarget === undefined) {
|
|
512
|
+
const doc = docForInstance(instance.key);
|
|
513
|
+
if (p.target === undefined && typeof p.targetId === 'number') {
|
|
514
|
+
const t = instances.get(p.targetId);
|
|
515
|
+
if (t !== undefined) p.target = doc.adopt(t);
|
|
516
|
+
}
|
|
517
|
+
if (p.currentTarget === undefined && entry.instanceId !== undefined) {
|
|
518
|
+
// id 0 = the island container — its facade is the doc's root.
|
|
519
|
+
p.currentTarget =
|
|
520
|
+
entry.instanceId === 0
|
|
521
|
+
? doc._root
|
|
522
|
+
: (instances.get(entry.instanceId) !== undefined
|
|
523
|
+
? doc.adopt(instances.get(entry.instanceId)!)
|
|
524
|
+
: undefined);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
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);
|
|
538
|
+
});
|
|
539
|
+
},
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* 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 —
|
|
545
|
+
* 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.
|
|
548
|
+
*/
|
|
549
|
+
setSize(instance: string, width: number, height: number): Op[] {
|
|
550
|
+
setInstanceSize(instance, width, height);
|
|
551
|
+
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);
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* 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
|
|
572
|
+
* handler dispatches then no-op via the mounts.get() guard.
|
|
573
|
+
*/
|
|
574
|
+
unmount(instance: string): Op[] {
|
|
575
|
+
const mounted = mounts.get(instance);
|
|
576
|
+
if (mounted === undefined) return [];
|
|
577
|
+
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, () => {
|
|
583
|
+
const imp = mounted.imperative;
|
|
584
|
+
imp.dispose?.(imp.doc);
|
|
585
|
+
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());
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Drain one instance's ops committed outside a sync task — passive effects
|
|
602
|
+
* (useEffect), timers, async setState. The pool protocol has no push
|
|
603
|
+
* channel, so the main thread polls this (or the doorbell pushes it).
|
|
604
|
+
*/
|
|
605
|
+
flush(instance: string): Op[] {
|
|
606
|
+
const mounted = mounts.get(instance);
|
|
607
|
+
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();
|
|
615
|
+
return takeOps(instance);
|
|
616
|
+
} finally {
|
|
617
|
+
setActiveInstance(prev);
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
/** The mounted instance's random id — the island's "worker pid" badge. */
|
|
622
|
+
whoami(instance: string): string {
|
|
623
|
+
return mounts.get(instance)?.pid ?? 'unmounted';
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* PolyWorker — one worker script serving a REGISTRY of islands:
|
|
631
|
+
* `mountIsland({ app: name })` picks one per island, and every island in
|
|
632
|
+
* the registry shares the worker's module graph, framework runtimes, and
|
|
633
|
+
* op pump. This is the bundle-optimization host shape — several islands,
|
|
634
|
+
* one worker to load and keep warm.
|
|
635
|
+
*/
|
|
636
|
+
export function definePolyWorker(
|
|
637
|
+
registry: PolyWorkerRegistry,
|
|
638
|
+
options?: { sharedMemory?: SharedMemory<DoorbellSpec> },
|
|
639
|
+
): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
|
|
640
|
+
for (const [key, app] of Object.entries(registry.apps)) {
|
|
641
|
+
const stamped = islandAppNameOf(app);
|
|
642
|
+
if (stamped !== undefined && stamped !== key) {
|
|
643
|
+
console.warn(
|
|
644
|
+
`[definePolyWorker] app registered as "${key}" but stamped "${stamped}" — ` +
|
|
645
|
+
`component-reference mounts resolve "${stamped}" and will fail. Fix the key or the stamp.`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
registerSingleApp(key, app);
|
|
649
|
+
}
|
|
650
|
+
return createIslandRuntime(registry.sharedMemory ?? options?.sharedMemory);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* MonoWorker — one worker script pinned to ONE island app (1:1). The
|
|
655
|
+
* isolation host shape: own bundle, own failure domain, nothing reachable
|
|
656
|
+
* outside the single app it serves. `mountIsland` can omit `app` — the
|
|
657
|
+
* worker resolves its sole app regardless of the supplied name.
|
|
658
|
+
*/
|
|
659
|
+
export function defineMonoWorker(
|
|
660
|
+
app: IslandApp,
|
|
661
|
+
options?: { sharedMemory?: SharedMemory<DoorbellSpec> },
|
|
662
|
+
): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
|
|
663
|
+
const stamp = (app as { islandAppName?: unknown }).islandAppName;
|
|
664
|
+
registerSingleApp(typeof stamp === 'string' && stamp !== '' ? stamp : 'main', app);
|
|
665
|
+
return createIslandRuntime(options?.sharedMemory);
|
|
666
|
+
}
|