@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.
- package/README.md +126 -0
- package/package.json +25 -0
- 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;
|