@atolljs/react-island 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.
Files changed (3) hide show
  1. package/README.md +126 -0
  2. package/package.json +25 -0
  3. package/src/index.tsx +492 -0
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # @atolljs/react-island
2
+
3
+ The React shell surface for `@atolljs/islands` — mount a
4
+ worker-hosted React (or imperative proxy-DOM) tree as an ordinary element
5
+ in a main-thread React app. The worker's render loop produces a serialized
6
+ op stream; this package's components own the mount lifecycle and replay it
7
+ onto a real `div`.
8
+
9
+ ```bash
10
+ npm install @atolljs/core @atolljs/islands @atolljs/react-island react
11
+ ```
12
+
13
+ ## `<Island/>`
14
+
15
+ ```tsx
16
+ import { Island } from '@atolljs/react-island';
17
+ import { ChartsApp } from './worker/apps';
18
+
19
+ const renderWorker = () =>
20
+ new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' });
21
+
22
+ <Island
23
+ worker={renderWorker} // or client={connectIslandWorker({...})}
24
+ app={ChartsApp} // or the registry name 'charts'
25
+ props={{ width: 520 }} // inferred from ChartsApp's own props
26
+ onEvent={(name, payload) => ...}
27
+ slots={{ preview: (el) => (el ? mountCanvas(el) : teardown()) }}
28
+ onReady={(island) => console.log(island.pid)}
29
+ className="island-box"
30
+ />
31
+ ```
32
+
33
+ **Mount by reference, not by string.** `app` accepts the registry key or the
34
+ app itself — stamp it once with `islandApp` and both sides share one handle:
35
+
36
+ ```ts
37
+ // worker/apps.tsx — the stamp is a data property, minification-proof
38
+ export const ChartsApp = islandApp('charts', function ChartsApp(props: ChartsProps) { ... });
39
+ export const mapApp = islandApp('map', { imperative: buildMap });
40
+ // worker entry: apps: { charts: ChartsApp, map: mapApp } (definePolyWorker
41
+ // warns if a stamp and its registry key drift apart)
42
+ ```
43
+
44
+ `IslandAppProps` infers `props` from the reference's signature — a React
45
+ component's own props or an imperative def's props parameter — so
46
+ `props={{missing: 1}}` fails to compile. Bare (unstamped) components fall
47
+ back to `displayName`/function name for the wire key — dev convenience;
48
+ bundlers mangle `fn.name`, which is what the stamp exists for.
49
+
50
+ The rendered `div` is the island's container — `className`/`style`/`data-*`
51
+ spread onto it. Mounting is async (worker spawn + first op batch in an
52
+ effect, cancellation-safe); `props` changes call `updateProps`, deduped by
53
+ serialized identity so equal props don't cost a round-trip. `onEvent`/
54
+ `slots`/`onActivity` are read through refs — fresh closures never remount.
55
+ `app` remounts on change; `worker`/`client` are mount-stable (use `key` to
56
+ swap). Unmount destroys the island and terminates its worker. All the
57
+ [islands island rules](../islands/README.md#island-rules) apply
58
+ unchanged.
59
+
60
+ `examples/react-dom-worker/react-shell.html` mounts all seven demo islands
61
+ this way — worth a look for the mediation pattern (`onEvent → setState →
62
+ <Island props>` replaces hand-wired `updateProps`).
63
+
64
+ ## Worker-loaded component proxies
65
+
66
+ `islandComponent` and `lazyIsland` go one step further: they return a
67
+ component that takes the **worker app's props inline** — the island looks
68
+ and types like a local component. Every prop that isn't a shell concern
69
+ (`worker`/`client`/`onEvent`/`onReady`/`onError`/`onActivity`/`slots`/
70
+ `fallback`/`containerProps`) forwards as the island's props.
71
+
72
+ ```tsx
73
+ import { islandComponent, lazyIsland } from '@atolljs/react-island';
74
+ import type { TableProps } from './worker/apps'; // type-only: erased, zero bundle cost
75
+
76
+ // The pure-contract proxy — the shell NEVER imports the implementation.
77
+ // Registry key + props type IS the contract; the worker owns the code.
78
+ const TableIsland = islandComponent<TableProps>('data-table');
79
+ <TableIsland worker={renderWorker} filter={filter} desc onEvent={…} />
80
+
81
+ // The React.lazy mirror — suspends while the module loads, then mounts by
82
+ // stamped component reference. The dynamic import is a real split point:
83
+ // the worker component's deps only load when the island mounts.
84
+ const ChartsIsland = lazyIsland(() =>
85
+ import('./worker/apps').then((m) => ({ default: m.ChartsApp })),
86
+ );
87
+ <Suspense fallback="loading…">
88
+ <ChartsIsland worker={renderWorker} width={520} />
89
+ </Suspense>
90
+ ```
91
+
92
+ One asymmetry vs `React.lazy`, by construction: the *loader* suspends but
93
+ the *mount* can't — suspended trees never commit, and mounting needs the
94
+ container div in the DOM. The mount window is covered by the `fallback`
95
+ prop instead of Suspense. `lazyIsland` also fixes the bundle caveat from
96
+ `<Island app={Comp}>`: a static import pulls the worker component's deps
97
+ (recharts in the demo: +~500 kB) into the shell chunk eagerly; the lazy
98
+ path splits them out, and `islandComponent` never imports them at all.
99
+
100
+ `islandComponent` overloads: `islandComponent<P>('name')` for the
101
+ type-contract form above, or `islandComponent(StampedApp)` to infer `P`
102
+ from a stamped reference the shell already has.
103
+
104
+ ## Worker topologies — registry, instance, shared client
105
+
106
+ Two worker entry points, mixable in one app:
107
+
108
+ ```ts
109
+ // render.worker.ts — REGISTRY worker: one script, many named apps
110
+ export const renderWorker = definePolyWorker({ apps: { charts: ChartsApp, table: TableApp } });
111
+
112
+ // map.worker.ts — REALM worker: one script, ONE app (1:1)
113
+ export const mapWorker = defineMonoWorker(mapApp);
114
+ ```
115
+
116
+ A instance worker's app resolves regardless of the requested registry name, so
117
+ the shell mounts it **namelessly** — `<Island worker={mapWorker}/>` or
118
+ `islandComponent<P>()` with no key — and its bundle carries only that app's
119
+ dependencies. `lazyIsland` accepts the worker module itself as the contract:
120
+ `lazyIsland(() => import('./worker/map.worker'))` resolves `{ app, worker }`
121
+ so the island carries its own worker.
122
+
123
+ For multi-island-per-worker, share a client: `client={connectIslandWorker({ worker })}`
124
+ mounts each island's instance into the SAME worker (separate reconcilers, op
125
+ queues, and pids — one OS thread). `destroy()` on one island unmounts just
126
+ its instance; the worker dies with the last island to leave.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@atolljs/react-island",
3
+ "version": "0.1.0",
4
+ "description": "React shell components for atoll-islands islands — <Island/>, islandComponent, lazyIsland",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.tsx"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "peerDependencies": {
16
+ "@atolljs/core": "0.1.0",
17
+ "@atolljs/islands": "0.1.0",
18
+ "react": "^19.3.0"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/jwhenry3/atolljs",
23
+ "directory": "packages/react-island"
24
+ }
25
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,492 @@
1
+ /**
2
+ * `@atolljs/react-island` — the React shell surface for
3
+ * `@atolljs/islands` islands: a worker-hosted React (or
4
+ * imperative proxy-DOM) tree mounted as an ordinary element in a
5
+ * main-thread React app.
6
+ *
7
+ * ```tsx
8
+ * import { Island } from '@atolljs/react-island';
9
+ * import { ChartsApp } from './worker/apps'; // the islandApp-stamped component
10
+ *
11
+ * const renderWorker = () =>
12
+ * new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' });
13
+ *
14
+ * <Island
15
+ * worker={renderWorker}
16
+ * app={ChartsApp} // or the registry name string 'charts'
17
+ * props={{ width: 520 }} // inferred from ChartsApp's own props
18
+ * onEvent={(name, payload) => setStatus(`${name}: ${JSON.stringify(payload)}`)}
19
+ * slots={{ wave: <Sparkline /> }} // React portals into worker data-atoll-slot="wave"
20
+ * />
21
+ * ```
22
+ *
23
+ * Semantics:
24
+ * - Mounting is async — the component renders its container div immediately,
25
+ * spawns the worker + replays the first op batch in an effect.
26
+ * - `props` changes call `island.updateProps` — deduped by JSON-serialized
27
+ * identity, so re-rendering with an equal props object costs no round-trip
28
+ * (props cross the wire serialized anyway, making that the honest equality).
29
+ * - `onEvent`/`onActivity` are read through refs — passing fresh closures
30
+ * each render never remounts the worker. `slots` are React portals into
31
+ * worker-created anchor elements; the portal content renders wherever the
32
+ * worker places the matching `data-atoll-slot`.
33
+ * - `app` changes remount the island; `worker`/`client` are MOUNT-STABLE —
34
+ * swap them via React `key`, not by passing a new value mid-life.
35
+ * - Unmount destroys the island; the worker terminates unless the client
36
+ * is SHARED (several `<Island client={c}/>` mount mounts into one worker —
37
+ * the instance unmounts and the worker dies with the last island to leave).
38
+ *
39
+ * The `app` prop accepts the registry name OR the app itself — a React
40
+ * component, an `islandApp`-stamped value, or an `{ imperative }` def. Pass
41
+ * the component to get `props` inference; `islandApp('name', Comp)` is the
42
+ * minification-proof way to bind a component to its registry key (bare
43
+ * function/displayName resolution is a dev convenience).
44
+ */
45
+ import { useEffect, useMemo, useRef, useState } from 'react';
46
+ import { createPortal } from 'react-dom';
47
+ import type { HTMLAttributes, ReactElement, ReactNode, Ref } from 'react';
48
+ import {
49
+ connectIslandWorker,
50
+ islandAppNameOf,
51
+ mountIsland,
52
+ } from '@atolljs/islands';
53
+ import type {
54
+ IslandAppLike,
55
+ IslandAppProps,
56
+ IslandClient,
57
+ IslandHandle,
58
+ IslandWorkerOptions,
59
+ Mode,
60
+ } from '@atolljs/islands';
61
+
62
+ // Re-export the app-contract types the component API is generic over, so
63
+ // consumers can name them without a second package import.
64
+ export type { IslandAppLike, IslandAppProps, IslandHandle };
65
+
66
+ /** What `app` accepts: the registry name, or a component/def to resolve. */
67
+ export type IslandAppRef<A> = A | string;
68
+
69
+ export interface IslandProps<A = string>
70
+ extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
71
+ /**
72
+ * Which app to mount — the `apps` registry key ('charts') or the app
73
+ * itself (`ChartsApp`, an `islandApp(...)`-stamped def, `{ imperative }`).
74
+ * A reference infers `props` from its own signature. Optional against a
75
+ * `defineMonoWorker` (1:1) worker — its single app mounts regardless.
76
+ */
77
+ app?: IslandAppRef<A>;
78
+ /**
79
+ * How to reach the worker — either a bundler-detectable factory
80
+ * `() => new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' })`
81
+ * (the common case) or a pre-connected `IslandClient`. Exactly one is required.
82
+ */
83
+ worker?: (() => Worker) | URL;
84
+ client?: IslandClient;
85
+ /** Extra pool options — concurrency, taskTimeout, respawn… (poolSize stays 1). */
86
+ workerOptions?: IslandWorkerOptions;
87
+ /**
88
+ * Initial flush mode — 'push' (default) drives op replay off the
89
+ * shared-memory doorbell (needs cross-origin isolation); 'poll' drains on
90
+ * a 50ms interval and, for `worker`-shorthand mounts, builds the client
91
+ * doorbell-free — no SharedArrayBuffer, no COOP/COEP requirement.
92
+ * Mount-time only; switch later via the handle's `setMode`.
93
+ */
94
+ mode?: Mode;
95
+ /** Initial + updated root props — serialized to the worker. */
96
+ props?: IslandAppProps<A>;
97
+ /** Island → shell channel: every `emit` op lands here. */
98
+ onEvent?: (name: string, payload: unknown) => void;
99
+ /**
100
+ * Transclusion slots — a worker `<div data-atoll-slot="name">` becomes a
101
+ * React portal mount point. The element's contents are rendered by React
102
+ * on the main thread; the worker only owns the empty anchor.
103
+ */
104
+ slots?: Record<string, ReactNode>;
105
+ /** Fired after each applied op batch — stats hooks. */
106
+ onActivity?: () => void;
107
+ /** The mounted handle (pid, updateProps, flush, setMode…) once ready. */
108
+ onReady?: (island: IslandHandle) => void;
109
+ /** Mount/update errors surface here instead of an unhandled rejection. */
110
+ onError?: (err: unknown) => void;
111
+ /** The container div (React 19 ref-as-prop). */
112
+ ref?: Ref<HTMLDivElement>;
113
+ }
114
+
115
+ export function Island<A = string>({
116
+ app,
117
+ worker,
118
+ client: clientProp,
119
+ workerOptions,
120
+ mode,
121
+ props,
122
+ onEvent,
123
+ onActivity,
124
+ slots,
125
+ onReady,
126
+ onError,
127
+ ref,
128
+ ...rest
129
+ }: IslandProps<A>): ReactElement {
130
+ const containerRef = useRef<HTMLDivElement | null>(null);
131
+ const islandRef = useRef<IslandHandle | null>(null);
132
+ const [slotTargets, setSlotTargets] = useState<Record<string, HTMLElement | null>>({});
133
+
134
+ // Latest-render values — the mount effect and driver callbacks read
135
+ // through these so fresh identities never force a remount.
136
+ const propsRef = useRef(props);
137
+ const onEventRef = useRef(onEvent);
138
+ const onActivityRef = useRef(onActivity);
139
+ const slotsRef = useRef(slots);
140
+ const onReadyRef = useRef(onReady);
141
+ const onErrorRef = useRef(onError);
142
+ propsRef.current = props;
143
+ onEventRef.current = onEvent;
144
+ onActivityRef.current = onActivity;
145
+ slotsRef.current = slots;
146
+ onReadyRef.current = onReady;
147
+ onErrorRef.current = onError;
148
+
149
+ const setRefs = (el: HTMLDivElement | null): void => {
150
+ containerRef.current = el;
151
+ if (typeof ref === 'function') ref(el);
152
+ else if (ref != null) ref.current = el;
153
+ };
154
+
155
+ // mount effect — worker/client identity is intentionally NOT tracked:
156
+ // mount-stable (swap via `key`). `app` IS tracked — a different app means
157
+ // a different island: the cleanup destroys, the re-run mounts fresh.
158
+ // No `app` → 'main': instance workers (defineMonoWorker) mount their single
159
+ // app namelessly; registry workers will error with the key list if 'main'
160
+ // isn't a real key.
161
+ const appName = app === undefined ? 'main' : islandAppNameOf(app);
162
+ useEffect(() => {
163
+ const el = containerRef.current;
164
+ if (el === null) return;
165
+ let cancelled = false;
166
+ let island: IslandHandle | null = null;
167
+ const report = (err: unknown): void => {
168
+ if (onErrorRef.current !== undefined) onErrorRef.current(err);
169
+ else console.error('[Island] mount failed:', err);
170
+ };
171
+
172
+ if (appName === undefined) {
173
+ report(
174
+ new Error(
175
+ '<Island> could not resolve an app name — pass the registry key or stamp the app with islandApp(name, app)',
176
+ ),
177
+ );
178
+ return;
179
+ }
180
+
181
+ const client =
182
+ clientProp ??
183
+ (worker !== undefined ? connectIslandWorker({ worker, ...workerOptions }) : undefined);
184
+ if (client === undefined) {
185
+ report(new Error('<Island> requires either `worker` or `client`'));
186
+ return;
187
+ }
188
+
189
+ // React portal anchors: the worker marks `data-atoll-slot="name"`, the
190
+ // driver hands us the real element, and we render the matching slot
191
+ // content into it with createPortal.
192
+ const slotProxy = new Proxy({} as Record<string, (el: HTMLElement | null) => void>, {
193
+ get: (_t, name: string) => (e: HTMLElement | null) => {
194
+ setSlotTargets((prev) => (prev[name] === e ? prev : { ...prev, [name]: e }));
195
+ },
196
+ });
197
+
198
+ void (async () => {
199
+ try {
200
+ const handle = await mountIsland({
201
+ client,
202
+ el,
203
+ app: appName,
204
+ mode,
205
+ // Serialized across the wire either way — cast preserves inference
206
+ // for props types without an index signature.
207
+ props: (propsRef.current ?? {}) as Record<string, unknown>,
208
+ onEvent: (name, payload) => onEventRef.current?.(name, payload),
209
+ onActivity: () => onActivityRef.current?.(),
210
+ slots: slotProxy,
211
+ });
212
+ if (cancelled) {
213
+ handle.destroy();
214
+ return;
215
+ }
216
+ island = handle;
217
+ islandRef.current = handle;
218
+ onReadyRef.current?.(handle);
219
+ } catch (err) {
220
+ if (!cancelled) report(err);
221
+ }
222
+ })();
223
+
224
+ return () => {
225
+ cancelled = true;
226
+ islandRef.current = null;
227
+ island?.destroy();
228
+ };
229
+ // eslint-disable-next-line react-hooks/exhaustive-deps
230
+ }, [app, appName, clientProp]);
231
+
232
+ // props → updateProps, deduped by wire-serialized identity (the same bytes
233
+ // produce the same render — a repeat send would be a wasted round-trip).
234
+ const propsJson = JSON.stringify(props ?? {});
235
+ useEffect(() => {
236
+ const island = islandRef.current;
237
+ if (island === null) return; // mount picks up propsRef.current
238
+ island.updateProps((props ?? {}) as Record<string, unknown>).catch((err) => {
239
+ if (onErrorRef.current !== undefined) onErrorRef.current(err);
240
+ else console.error('[Island] updateProps failed:', err);
241
+ });
242
+ // eslint-disable-next-line react-hooks/exhaustive-deps
243
+ }, [propsJson]);
244
+
245
+ return (
246
+ <>
247
+ <div ref={setRefs} {...rest} />
248
+ {Object.entries(slotTargets).map(([name, el]) =>
249
+ el && slotsRef.current?.[name] != null
250
+ ? createPortal(slotsRef.current[name] as ReactNode, el, `atoll-slot-${name}`)
251
+ : null,
252
+ )}
253
+ </>
254
+ );
255
+ }
256
+
257
+ /* ── Worker-loaded component proxies ──────────────────────────────────────
258
+ *
259
+ * `islandComponent`/`lazyIsland` return components that take a worker app's
260
+ * props inline — `<ChartsIsland width={520}/>` — and route everything that
261
+ * isn't a shell concern through as the island's props. The implementation is
262
+ * shared (`ProxyIsland`) so both APIs differ only in how the app reference is
263
+ * produced: `islandComponent` binds it eagerly, `lazyIsland` suspends while a
264
+ * dynamic import resolves (and can additionally supply a worker factory).
265
+ */
266
+
267
+ /**
268
+ * Props consumed by a proxy component itself — everything else forwards to
269
+ * the island as its props.
270
+ */
271
+ export interface IslandShellProps {
272
+ /** How to reach the worker — see IslandProps.worker/client. */
273
+ worker?: (() => Worker) | URL;
274
+ client?: IslandClient;
275
+ workerOptions?: IslandWorkerOptions;
276
+ /** Initial flush mode — see IslandProps.mode. */
277
+ mode?: Mode;
278
+ /** Island → shell channel: every `emit` op lands here. */
279
+ onEvent?: (name: string, payload: unknown) => void;
280
+ /** Fired after each applied op batch. */
281
+ onActivity?: () => void;
282
+ /** The mounted handle (pid, updateProps, flush, setMode…) once ready. */
283
+ onReady?: (island: IslandHandle) => void;
284
+ /** Mount/update errors surface here instead of an unhandled rejection. */
285
+ onError?: (err: unknown) => void;
286
+ /** Transclusion slots — see IslandProps.slots. */
287
+ slots?: Record<string, ReactNode>;
288
+ /** Rendered while the worker mounts — the lazy-side fallback. */
289
+ fallback?: ReactNode;
290
+ /**
291
+ * Attributes for the island's container div (className/id/style/data-*).
292
+ * `onError` is omitted — it collides with the mount-error callback.
293
+ */
294
+ containerProps?: Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onError'>;
295
+ }
296
+
297
+ const SHELL_PROP_KEYS: ReadonlySet<string> = new Set([
298
+ 'worker',
299
+ 'client',
300
+ 'workerOptions',
301
+ 'mode',
302
+ 'onEvent',
303
+ 'onActivity',
304
+ 'onReady',
305
+ 'onError',
306
+ 'slots',
307
+ 'fallback',
308
+ 'containerProps',
309
+ ]);
310
+
311
+ /** Every non-shell prop is the island's props — the component contract. */
312
+ const splitProxyProps = (
313
+ raw: Record<string, unknown>,
314
+ ): { shell: IslandShellProps; islandProps: Record<string, unknown> } => {
315
+ const shell: Record<string, unknown> = {};
316
+ const islandProps: Record<string, unknown> = {};
317
+ for (const [key, value] of Object.entries(raw)) {
318
+ if (SHELL_PROP_KEYS.has(key)) shell[key] = value;
319
+ else islandProps[key] = value;
320
+ }
321
+ return { shell: shell as IslandShellProps, islandProps };
322
+ };
323
+
324
+ /* ── Shared proxy implementation ─────────────────────────────────────────── */
325
+
326
+ type LazyModule<A> = A | { default: A } | { app: A; worker?: (() => Worker) | URL };
327
+
328
+ /** Cached state for a lazy loader. One cache entry per loader function. */
329
+ interface LazyState {
330
+ promise: Promise<unknown>;
331
+ value?: IslandAppLike;
332
+ worker?: (() => Worker) | URL;
333
+ error?: unknown;
334
+ }
335
+
336
+ const lazyCache = new WeakMap<() => Promise<LazyModule<IslandAppLike>>, LazyState>();
337
+
338
+ /** Resolve a lazy module through Suspense; reused across re-renders. */
339
+ function useLazyApp(loader: () => Promise<LazyModule<IslandAppLike>>): {
340
+ app?: IslandAppLike;
341
+ worker?: (() => Worker) | URL;
342
+ } {
343
+ let state = lazyCache.get(loader);
344
+ if (state === undefined) {
345
+ state = {
346
+ promise: Promise.resolve()
347
+ .then(loader)
348
+ .then((mod) => {
349
+ const unwrapped =
350
+ mod !== null && typeof mod === 'object' && 'default' in mod
351
+ ? (mod as { default: unknown }).default
352
+ : mod;
353
+ const isContract =
354
+ unwrapped !== null && typeof unwrapped === 'object' && 'app' in unwrapped;
355
+ const resolvedApp = isContract
356
+ ? (unwrapped as { app: IslandAppLike }).app
357
+ : (unwrapped as IslandAppLike);
358
+ const resolvedWorker = isContract
359
+ ? (unwrapped as { worker?: (() => Worker) | URL }).worker
360
+ : undefined;
361
+ state!.value = resolvedApp;
362
+ state!.worker = resolvedWorker;
363
+ return resolvedApp;
364
+ })
365
+ .catch((err: unknown) => {
366
+ state!.error = err;
367
+ throw err;
368
+ }),
369
+ };
370
+ lazyCache.set(loader, state);
371
+ }
372
+ if (state.error !== undefined) throw state.error;
373
+ if (state.value === undefined) throw state.promise;
374
+ return { app: state.value, worker: state.worker };
375
+ }
376
+
377
+ type AppSource =
378
+ | { kind: 'static'; app?: IslandAppLike }
379
+ | { kind: 'lazy'; loader: () => Promise<LazyModule<IslandAppLike>> };
380
+
381
+ function ProxyIsland({
382
+ source,
383
+ ...raw
384
+ }: { source: AppSource } & Record<string, unknown>): ReactElement {
385
+ const resolved =
386
+ source.kind === 'static' ? { app: source.app } : useLazyApp(source.loader);
387
+ const { shell, islandProps } = splitProxyProps(raw);
388
+ const [ready, setReady] = useState(false);
389
+ return (
390
+ <>
391
+ {!ready ? shell.fallback : null}
392
+ <Island
393
+ app={resolved.app as IslandAppRef<Record<string, unknown>> | undefined}
394
+ worker={shell.worker ?? resolved.worker}
395
+ client={shell.client}
396
+ workerOptions={shell.workerOptions}
397
+ mode={shell.mode}
398
+ props={islandProps as Record<string, unknown>}
399
+ onEvent={shell.onEvent}
400
+ onActivity={shell.onActivity}
401
+ slots={shell.slots}
402
+ onReady={(h) => {
403
+ shell.onReady?.(h);
404
+ setReady(true);
405
+ }}
406
+ onError={shell.onError}
407
+ {...shell.containerProps}
408
+ />
409
+ </>
410
+ );
411
+ }
412
+
413
+ /**
414
+ * The app a lazy module resolves to — unwraps `{ default: A }` and the
415
+ * contract-module shape `{ app: A, worker? }`, passes bare A through.
416
+ */
417
+ export type LazyResolvedApp<T extends Promise<unknown>> =
418
+ Awaited<T> extends { app: infer A }
419
+ ? A
420
+ : Awaited<T> extends { default: infer D }
421
+ ? D
422
+ : Awaited<T>;
423
+
424
+ /**
425
+ * `islandComponent<P>('charts')` — a proxy component for a worker app that
426
+ * the shell NEVER imports. `P` is the contract (usually an `import type` of
427
+ * the worker component's props); the string is the registry key. Against a
428
+ * `defineMonoWorker` (1:1) worker the name can be omitted entirely.
429
+ *
430
+ * ```tsx
431
+ * import type { TableProps } from './worker/apps';
432
+ * const TableIsland = islandComponent<TableProps>('data-table');
433
+ * <TableIsland worker={renderWorker} filter={filter} desc={desc} />
434
+ * ```
435
+ */
436
+ export function islandComponent<P extends object = Record<string, unknown>>(
437
+ app?: string,
438
+ ): (props: P & IslandShellProps) => ReactElement;
439
+ export function islandComponent<A extends IslandAppLike>(
440
+ app: A,
441
+ ): (props: IslandAppProps<A> & IslandShellProps) => ReactElement;
442
+ export function islandComponent(
443
+ app?: string | IslandAppLike,
444
+ ): (props: object) => ReactElement {
445
+ const Proxy = (props: Record<string, unknown>): ReactElement => (
446
+ <ProxyIsland source={{ kind: 'static', app: app as IslandAppLike }} {...props} />
447
+ );
448
+ (Proxy as { displayName?: string }).displayName =
449
+ `IslandComponent(${islandAppNameOf(app) ?? 'main'})`;
450
+ return Proxy as (props: object) => ReactElement;
451
+ }
452
+
453
+ /**
454
+ * `lazyIsland(() => import('./worker/apps').then(m => ({ default: m.ChartsApp })))`
455
+ * — the `React.lazy` mirror for worker apps. The returned component suspends
456
+ * while the module loads (wrap it in `<Suspense>`), then proxies all props to
457
+ * the island. The dynamic import gives bundlers a split point, so the worker
458
+ * component's dependencies only load when the island mounts.
459
+ *
460
+ * ```tsx
461
+ * const ChartsIsland = lazyIsland(() => import('./worker/apps').then(m => ({ default: m.ChartsApp })));
462
+ * <Suspense fallback="loading…"><ChartsIsland width={520} worker={renderWorker} /></Suspense>
463
+ * ```
464
+ *
465
+ * CONTRACT MODULES: for `defineMonoWorker` (1:1) topologies the loader can
466
+ * resolve `{ app, worker }` — the island then carries its own worker factory
467
+ * and call sites need no `worker` prop at all:
468
+ *
469
+ * ```ts
470
+ * // worker/map.island.ts — shell-safe contract (never the .worker.ts entry
471
+ * // itself: defining a worker installs instance globals, don't import it)
472
+ * export { mapApp as app } from './map';
473
+ * export const worker = () => new Worker(new URL('./map.worker.ts', import.meta.url), { type: 'module' });
474
+ *
475
+ * // shell.tsx
476
+ * const MapIsland = lazyIsland(() => import('./worker/map.island'));
477
+ * <MapIsland /> // app + worker both from the contract
478
+ * ```
479
+ */
480
+ export function lazyIsland<T extends Promise<LazyModule<IslandAppLike>>>(
481
+ loader: () => T,
482
+ ): (props: IslandAppProps<LazyResolvedApp<T>> & IslandShellProps) => ReactElement {
483
+ const Proxy = (props: Record<string, unknown>): ReactElement => (
484
+ <ProxyIsland source={{ kind: 'lazy', loader: loader as () => Promise<LazyModule<IslandAppLike>> }} {...props} />
485
+ );
486
+ (Proxy as { displayName?: string }).displayName = 'LazyIsland';
487
+ return Proxy as (props: object) => ReactElement as (
488
+ props: IslandAppProps<LazyResolvedApp<T>> & IslandShellProps,
489
+ ) => ReactElement;
490
+ }
491
+
492
+ export default Island;