@atolljs/react-island 0.1.0 → 0.1.2

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 CHANGED
@@ -1,13 +1,17 @@
1
1
  # @atolljs/react-island
2
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`.
3
+ The React shell surface for `@atolljs/islands` — mount a worker-hosted React
4
+ (or imperative proxy-DOM) tree as an ordinary element in a main-thread React
5
+ app. The worker's render loop produces a serialized op stream; this package's
6
+ components own the mount lifecycle and replay it onto a real `div`.
7
+
8
+ **[Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/quickstart/)**
9
+
10
+ ## Install
8
11
 
9
12
  ```bash
10
- npm install @atolljs/core @atolljs/islands @atolljs/react-island react
13
+ npm install @atolljs/core @atolljs/islands @atolljs/react-island react react-dom
14
+ npm install react-reconciler # worker islands only — the /worker entry's renderer
11
15
  ```
12
16
 
13
17
  ## `<Island/>`
@@ -37,50 +41,31 @@ app itself — stamp it once with `islandApp` and both sides share one handle:
37
41
  // worker/apps.tsx — the stamp is a data property, minification-proof
38
42
  export const ChartsApp = islandApp('charts', function ChartsApp(props: ChartsProps) { ... });
39
43
  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)
44
+ // worker entry: apps: { charts: ChartsApp, map: mapApp }
42
45
  ```
43
46
 
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`).
47
+ `IslandAppProps` infers `props` from the reference's signature, so
48
+ `props={{missing: 1}}` fails to compile. The rendered `div` is the island's
49
+ container — `className`/`style`/`data-*` spread onto it. Mounting is async and
50
+ cancellation-safe; `props` changes call `updateProps` (deduped by serialized
51
+ identity); unmount destroys the island and terminates its worker.
63
52
 
64
53
  ## Worker-loaded component proxies
65
54
 
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.
55
+ `islandComponent` and `lazyIsland` return a component that takes the **worker
56
+ app's props inline** — the island looks and types like a local component:
71
57
 
72
58
  ```tsx
73
59
  import { islandComponent, lazyIsland } from '@atolljs/react-island';
74
- import type { TableProps } from './worker/apps'; // type-only: erased, zero bundle cost
60
+ import type { TableProps } from './worker/apps'; // type-only: zero bundle cost
75
61
 
76
- // The pure-contract proxy — the shell NEVER imports the implementation.
77
- // Registry key + props type IS the contract; the worker owns the code.
62
+ // Pure-contract proxy — the shell NEVER imports the implementation.
78
63
  const TableIsland = islandComponent<TableProps>('data-table');
79
64
  <TableIsland worker={renderWorker} filter={filter} desc onEvent={…} />
80
65
 
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.
66
+ // React.lazy mirror — suspends on the module load (a real split point:
67
+ // the worker component's deps only load when the island mounts), then
68
+ // mounts by stamped reference.
84
69
  const ChartsIsland = lazyIsland(() =>
85
70
  import('./worker/apps').then((m) => ({ default: m.ChartsApp })),
86
71
  );
@@ -89,38 +74,46 @@ const ChartsIsland = lazyIsland(() =>
89
74
  </Suspense>
90
75
  ```
91
76
 
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.
77
+ The mount window is covered by the `fallback` prop (suspended trees never
78
+ commit, so mounting can't suspend). `islandComponent` also takes a stamped
79
+ reference (`islandComponent(StampedApp)`) to infer `P` instead of a key +
80
+ type parameter.
103
81
 
104
- ## Worker topologies — registry, instance, shared client
82
+ ## Worker topologies
105
83
 
106
- Two worker entry points, mixable in one app:
84
+ The worker side lives in `@atolljs/react-island/worker` — the entry that
85
+ pulls `react` + `react-reconciler` into the worker bundle:
107
86
 
108
87
  ```ts
109
88
  // render.worker.ts — REGISTRY worker: one script, many named apps
110
- export const renderWorker = definePolyWorker({ apps: { charts: ChartsApp, table: TableApp } });
89
+ import { defineReactPolyWorker } from '@atolljs/react-island/worker';
90
+ export const renderWorker = defineReactPolyWorker({ apps: { charts: ChartsApp, table: TableApp } });
111
91
 
112
- // map.worker.ts — REALM worker: one script, ONE app (1:1)
113
- export const mapWorker = defineMonoWorker(mapApp);
92
+ // map.worker.ts — MONO worker: one script, ONE app (1:1), mounted namelessly
93
+ import { defineReactMonoWorker } from '@atolljs/react-island/worker';
94
+ export const mapWorker = defineReactMonoWorker(mapApp);
114
95
  ```
115
96
 
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.
97
+ For a mixed-framework registry, use `@atolljs/islands/worker`'s
98
+ `definePolyWorker` and wrap each React app in `reactIslandApp(App)` —
99
+ non-React registries never see this entry, so they ship zero React.
100
+ `<Slot/>`, `emit`, and `runInInstance` re-export from `/worker` too, so an
101
+ app's worker imports need no second specifier.
102
+
103
+ A mono worker's app resolves regardless of the requested registry name — mount
104
+ it with `<Island worker={mapWorker}/>` or `islandComponent<P>()` with no key.
105
+ `lazyIsland` accepts the worker module itself as the contract:
106
+ `lazyIsland(() => import('./worker/map.worker'))` resolves `{ app, worker }`.
107
+
108
+ For multi-island-per-worker, share a client:
109
+ `client={connectIslandWorker({ worker })}` mounts each island's instance into
110
+ the SAME worker (separate reconcilers, op queues, and pids — one OS thread).
111
+
112
+ ## Documentation
113
+
114
+ - [Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/quickstart/)
115
+ - [Worker islands for React](https://jwhenry3.github.io/atolljs/consumer/fw-react/worker-islands/)
116
+ - [Worker islands](https://jwhenry3.github.io/atolljs/consumer/islands/) —
117
+ `mountIsland` options, `IslandHandle`, island rules
118
+ - In-repo internals: [`docs/islands.md`](../../docs/islands.md),
119
+ [`docs/islands-worker.md`](../../docs/islands-worker.md)
package/package.json CHANGED
@@ -1,25 +1,38 @@
1
1
  {
2
2
  "name": "@atolljs/react-island",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "React shell components for atoll-islands islands — <Island/>, islandComponent, lazyIsland",
5
+ "homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
5
6
  "type": "module",
6
7
  "exports": {
7
- ".": "./src/index.tsx"
8
+ ".": "./src/index.tsx",
9
+ "./worker": "./src/worker.ts"
8
10
  },
9
11
  "files": [
10
12
  "src"
11
13
  ],
12
14
  "publishConfig": {
13
- "access": "public"
15
+ "access": "public",
16
+ "registry": "https://registry.npmjs.org"
14
17
  },
15
18
  "peerDependencies": {
16
- "@atolljs/core": "0.1.0",
17
- "@atolljs/islands": "0.1.0",
18
- "react": "^19.3.0"
19
+ "@atolljs/core": "0.1.2",
20
+ "@atolljs/islands": "0.1.2",
21
+ "react": "^19.3.0",
22
+ "react-dom": "^19.3.0",
23
+ "react-reconciler": "^0.34.0"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "react-reconciler": {
27
+ "optional": true
28
+ }
19
29
  },
20
30
  "repository": {
21
31
  "type": "git",
22
32
  "url": "https://github.com/jwhenry3/atolljs",
23
33
  "directory": "packages/react-island"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/jwhenry3/atolljs/issues"
24
37
  }
25
38
  }
@@ -0,0 +1,449 @@
1
+ /**
2
+ * React-reconciler host adapter for the worker DOM backend.
3
+ *
4
+ * The generic instance state (op queues, instance ids, handler registry,
5
+ * doorbell, geometry) lives in `@atolljs/islands/worker`'s instance module.
6
+ * This file only wires the reconciler's create/append/update/remove/commit
7
+ * hooks to that backend.
8
+ *
9
+ * react-reconciler@0.34 reads ~150 fields off the config object. Most are
10
+ * stubs for features this renderer doesn't implement (hydration,
11
+ * persistence, resources, singletons, view transitions, test selectors,
12
+ * scope/hydratable APIs) — they exist so nothing throws when the reconciler
13
+ * destructures them; they are never invoked behind the `supports*` flags.
14
+ */
15
+
16
+ import { createContext } from 'react';
17
+ import {
18
+ bumpOpsVersion,
19
+ docForInstance,
20
+ getActiveInstance,
21
+ getLastActiveInstance,
22
+ instances,
23
+ newElement,
24
+ newText,
25
+ pushOp,
26
+ serializeProps,
27
+ unregisterHandler,
28
+ type ElementInstance,
29
+ type HostInstance,
30
+ type ProxyNode,
31
+ type RootContainer,
32
+ type TextInstance,
33
+ } from '@atolljs/islands/worker';
34
+
35
+ /* ── Event-priority plumbing ────────────────────────────────────────────── */
36
+ /*
37
+ * React 19 expresses event priorities as lane numbers: 2 = discrete
38
+ * (click/keydown → SyncLane), 8 = continuous, 32 = default, 0 = none. The
39
+ * reconciler calls setCurrentUpdatePriority around its own flushes
40
+ * (flushSyncFromReconciler sets 2), so honoring the set/get pair and echoing
41
+ * the value back from resolveUpdatePriority is all that's needed.
42
+ */
43
+ let currentUpdatePriority = 0;
44
+ const DEFAULT_EVENT_PRIORITY = 32;
45
+
46
+ /* ── React-19 transition context (must be real React objects) ───────────── */
47
+
48
+ const NotPendingTransition = Object.freeze({ pending: false, data: null, method: null, action: null });
49
+ const HostTransitionContext = createContext(NotPendingTransition);
50
+
51
+ const noop = (): void => {};
52
+ const NULL = (): null => null;
53
+ const FALSE = (): boolean => false;
54
+ const TRUE = (): boolean => true;
55
+
56
+ /* ── Refs & portals ───────────────────────────────────────────────────────
57
+ *
58
+ * React refs must receive an object the COMPONENT treats as "the element" —
59
+ * for React DOM that's the real DOM node, for us the closest truthful thing
60
+ * is the proxy DOM's facade (it navigates the shadow tree, mutates via ops,
61
+ * and reports `nodeType === 1`). getPublicInstance therefore adopts the
62
+ * host instance into a per-instance proxy document, created lazily so React
63
+ * mounts only pay for it when something actually holds a ref.
64
+ *
65
+ * This is what makes react-dom's `createPortal(children, ref.current)` work
66
+ * here: isValidContainer() only checks nodeType, the portal fiber then feeds
67
+ * the facade back as `containerInfo`, and the container-level methods below
68
+ * unwrap `.instance` so portal children land inside the real target node —
69
+ * that is the entire recharts <Tooltip>/<Legend> path.
70
+ */
71
+ const publicInstanceFor = (instance: HostInstance): ProxyNode | HostInstance =>
72
+ docForInstance(instance.instance).adopt(instance);
73
+
74
+ /**
75
+ * Portal `containerInfo` is the public instance (a ProxyElement) the caller
76
+ * passed to createPortal; the ROOT_CONTAINER sentinel and raw instances keep
77
+ * their own `id`. Anything unrecognizable falls back to the island root.
78
+ */
79
+ const containerParentId = (container: unknown): number => {
80
+ const inst = (container as { instance?: { id?: unknown } } | null)?.instance;
81
+ if (inst && typeof inst.id === 'number') return inst.id;
82
+ const id = (container as { id?: unknown } | null)?.id;
83
+ return typeof id === 'number' ? id : 0;
84
+ };
85
+
86
+ /**
87
+ * The instance a create should bind to — read off the root container for
88
+ * ordinary renders, off the portal container's wrapped instance for portal
89
+ * subtrees (a ProxyElement, whose `.instance` field doesn't exist but whose
90
+ * `.instance.instance` does), finally falling back to ambient instance state.
91
+ */
92
+ const containerInstance = (container: unknown): string => {
93
+ // `.instance` is the instance KEY on a RootContainer/raw host record and
94
+ // the host RECORD on a ProxyElement (portal containers) — disambiguate
95
+ // by shape: a string IS the key; a record holds `.instance.instance`.
96
+ const inner = (container as { instance?: unknown } | null)?.instance;
97
+ if (typeof inner === 'string') return inner;
98
+ return (inner as { instance?: string } | undefined)?.instance ??
99
+ (getActiveInstance() !== '' ? getActiveInstance() : getLastActiveInstance());
100
+ };
101
+
102
+ /* Host context = DOM namespace tracking, same role it plays in React DOM:
103
+ * `getChildHostContext` flips the namespace when the element type requires
104
+ * it, and `createInstance` stamps the result on the instance so the `create`
105
+ * op can tell the driver to createElementNS. Recharts-style SVG trees need
106
+ * this — without it every <svg>/<path> would arrive as an HTMLUnknownElement. */
107
+ interface HostContext {
108
+ ns?: string;
109
+ }
110
+ const SVG_NS = 'http://www.w3.org/2000/svg';
111
+ const MATH_NS = 'http://www.w3.org/1998/Math/MathML';
112
+ const HTML_CTX: HostContext = Object.freeze({});
113
+ const SVG_CTX: HostContext = Object.freeze({ ns: SVG_NS });
114
+ const MATH_CTX: HostContext = Object.freeze({ ns: MATH_NS });
115
+
116
+ /** The namespace context a host element gives its children — and the
117
+ * namespace the element itself belongs to (getChildNamespace semantics:
118
+ * <svg> in HTML is SVG, foreignObject/desc/title flip back to HTML). */
119
+ const childHostContextFor = (parent: HostContext, type: string): HostContext => {
120
+ if (type === 'svg') return SVG_CTX;
121
+ if (type === 'math') return MATH_CTX;
122
+ if (type === 'foreignObject' || type === 'desc' || type === 'title') return HTML_CTX;
123
+ return parent;
124
+ };
125
+
126
+ /* ── The host config ────────────────────────────────────────────────────── */
127
+
128
+ export const hostConfig = {
129
+ // Identity / capabilities
130
+ rendererVersion: '0.34.0',
131
+ rendererPackageName: '@atolljs/react-island',
132
+ extraDevToolsConfig: null,
133
+ isPrimaryRenderer: true,
134
+ warnsIfNotActing: false,
135
+ supportsMutation: true,
136
+ supportsPersistence: false,
137
+ supportsHydration: false,
138
+ supportsResources: false,
139
+ supportsSingletons: false,
140
+ supportsMicrotasks: false,
141
+ supportsTestSelectors: false,
142
+
143
+ getPublicInstance: (instance: HostInstance): unknown => publicInstanceFor(instance),
144
+ // Host contexts must be non-null objects (the reconciler pushes them on a
145
+ // context stack and warns "Expected host context to exist" on null). This
146
+ // renderer carries no per-subtree context — echo a single frozen sentinel.
147
+ // Host contexts must be non-null objects (the reconciler warns on null).
148
+ // Ours carries only the element namespace: <svg>/<math> enter their
149
+ // namespaces; foreignObject/desc/title are HTML-integration points that
150
+ // flip children back to HTML; everything else inherits the parent's.
151
+ // For portals, this becomes the context the portal subtree renders under —
152
+ // derived from the container's own namespace so children of an SVG portal
153
+ // target (recharts zIndex <g> layers) stay in the SVG namespace.
154
+ getRootHostContext: (container: unknown): HostContext => {
155
+ const ns = (container as { instance?: { ns?: string } } | null)?.instance?.ns;
156
+ if (ns === SVG_NS) return SVG_CTX;
157
+ if (ns === MATH_NS) return MATH_CTX;
158
+ return HTML_CTX;
159
+ },
160
+ getChildHostContext: (parent: HostContext, type: string): HostContext =>
161
+ childHostContextFor(parent, type),
162
+ prepareForCommit: NULL,
163
+ // The doorbell: every commit bumps opsVersion once — the main thread's
164
+ // observe() wakes (Atomics.waitAsync) and flushes the op queue, so commits
165
+ // made outside task calls (effects, timers, async setState) arrive as a
166
+ // push instead of waiting on a poll. Task-returned ops bump too; the
167
+ // follow-up flush just finds an empty queue.
168
+ resetAfterCommit: bumpOpsVersion,
169
+
170
+ // Creation — emit ops
171
+ createInstance: (
172
+ type: string,
173
+ props: Record<string, unknown>,
174
+ rootContainer: unknown,
175
+ hostContext: unknown,
176
+ _internalHandle: unknown,
177
+ ): ElementInstance => {
178
+ // The element's own namespace = the child context its TYPE produces
179
+ // under the parent's context — the same computation React DOM runs
180
+ // (getChildNamespace): <svg> in HTML is itself SVG, <foreignObject> in
181
+ // SVG is itself HTML. Instance comes from the container (see RootContainer).
182
+ const ctx = childHostContextFor(hostContext as HostContext, type);
183
+ const instance = newElement(type, props, ctx.ns, containerInstance(rootContainer));
184
+ pushOp(instance.instance, {
185
+ t: 'create',
186
+ id: instance.id,
187
+ type,
188
+ props: instance.props,
189
+ ns: instance.ns,
190
+ });
191
+ return instance;
192
+ },
193
+ createTextInstance: (
194
+ text: string,
195
+ rootContainer: unknown,
196
+ _hostContext: unknown,
197
+ _internalHandle: unknown,
198
+ ): TextInstance => {
199
+ const instance = newText(text, containerInstance(rootContainer));
200
+ pushOp(instance.instance, { t: 'text', id: instance.id, text });
201
+ return instance;
202
+ },
203
+ finalizeInitialChildren: FALSE,
204
+ // Always false: string/number children become real text instances, so the
205
+ // tree arrives as linear ops and `utext` handles every text update. (The
206
+ // `true` path would expect commitUpdate to set textContent from
207
+ // props.children — which we deliberately never serialize.)
208
+ shouldSetTextContent: FALSE,
209
+
210
+ // Tree wiring — emit ops
211
+ appendInitialChild: (parent: HostInstance, child: HostInstance): void => {
212
+ pushOp(parent.instance, { t: 'append', parent: parent.id, child: child.id });
213
+ },
214
+ appendChild: (parent: HostInstance, child: HostInstance): void => {
215
+ pushOp(parent.instance, { t: 'append', parent: parent.id, child: child.id });
216
+ },
217
+ appendChildToContainer: (container: unknown, child: HostInstance): void => {
218
+ pushOp(child.instance, { t: 'append', parent: containerParentId(container), child: child.id });
219
+ },
220
+ insertBefore: (parent: HostInstance, child: HostInstance, before: HostInstance): void => {
221
+ pushOp(parent.instance, { t: 'append', parent: parent.id, child: child.id, before: before.id });
222
+ },
223
+ insertInContainerBefore: (container: unknown, child: HostInstance, before: HostInstance): void => {
224
+ pushOp(child.instance, {
225
+ t: 'append',
226
+ parent: containerParentId(container),
227
+ child: child.id,
228
+ before: before.id,
229
+ });
230
+ },
231
+ removeChild: (_parent: HostInstance, child: HostInstance): void => {
232
+ pushOp(child.instance, { t: 'remove', child: child.id });
233
+ },
234
+ removeChildFromContainer: (_container: unknown, child: HostInstance): void => {
235
+ pushOp(child.instance, { t: 'remove', child: child.id });
236
+ },
237
+ // No instance argument — routed by the task's active instance (only reached
238
+ // inside mount/remount's syncCommit).
239
+ clearContainer: (): void => {
240
+ pushOp(getActiveInstance(), { t: 'clear' });
241
+ },
242
+
243
+ // Updates — emit ops
244
+ commitUpdate: (
245
+ instance: ElementInstance,
246
+ _type: string,
247
+ _oldProps: Record<string, unknown>,
248
+ newProps: Record<string, unknown>,
249
+ _internalHandle: unknown,
250
+ ): void => {
251
+ // Serialize first so the op carries the latest props; re-serialization
252
+ // also re-registers event handlers against the current closure.
253
+ instance.props = serializeProps(instance, newProps);
254
+ pushOp(instance.instance, { t: 'update', id: instance.id, props: instance.props });
255
+ },
256
+ commitTextUpdate: (instance: TextInstance, _oldText: string, newText: string): void => {
257
+ instance.text = newText;
258
+ pushOp(instance.instance, { t: 'utext', id: instance.id, text: newText });
259
+ },
260
+ commitMount: noop,
261
+ // Unreachable while shouldSetTextContent is always false; still emit the
262
+ // spec'd op for completeness.
263
+ resetTextContent: (instance: ElementInstance): void => {
264
+ instance.props = {};
265
+ pushOp(instance.instance, { t: 'update', id: instance.id, props: {} });
266
+ },
267
+
268
+ // Suspense visibility — `hidden` is a global HTML attribute the main
269
+ // thread applies like any other prop.
270
+ hideInstance: (instance: ElementInstance): void => {
271
+ pushOp(instance.instance, { t: 'update', id: instance.id, props: { ...instance.props, hidden: true } });
272
+ },
273
+ unhideInstance: (instance: ElementInstance): void => {
274
+ pushOp(instance.instance, { t: 'update', id: instance.id, props: { ...instance.props } });
275
+ },
276
+ hideTextInstance: (instance: TextInstance): void => {
277
+ pushOp(instance.instance, { t: 'utext', id: instance.id, text: '' });
278
+ },
279
+ unhideTextInstance: (instance: TextInstance, text: string): void => {
280
+ pushOp(instance.instance, { t: 'utext', id: instance.id, text });
281
+ },
282
+
283
+ // GC hook — drop the record and its handler slots.
284
+ detachDeletedInstance: (instance: HostInstance): void => {
285
+ instances.delete(instance.id);
286
+ if (instance.kind === 'element') {
287
+ for (const hid of Object.values(instance.listenerSlots)) unregisterHandler(hid);
288
+ }
289
+ },
290
+
291
+ // Scheduler
292
+ scheduleTimeout: setTimeout,
293
+ cancelTimeout: clearTimeout,
294
+ noTimeout: -1,
295
+ // supportsMicrotasks is false, so the reconciler schedules root work via
296
+ // the `scheduler` package (MessageChannel in a worker). This export is
297
+ // still destructured — provide a real one for completeness.
298
+ scheduleMicrotask: (fn: () => void): void => queueMicrotask(fn),
299
+
300
+ // Event priorities (see note above — values are lane numbers).
301
+ setCurrentUpdatePriority: (p: number): void => {
302
+ currentUpdatePriority = p;
303
+ },
304
+ getCurrentUpdatePriority: (): number => currentUpdatePriority,
305
+ resolveUpdatePriority: (): number =>
306
+ currentUpdatePriority !== 0 ? currentUpdatePriority : DEFAULT_EVENT_PRIORITY,
307
+ resolveEventType: NULL,
308
+ resolveEventTimeStamp: (): number => -1.1,
309
+ trackSchedulerEvent: noop,
310
+ shouldAttemptEagerTransition: FALSE,
311
+
312
+ // Events/portals/scopes — no DOM event system exists worker-side.
313
+ getInstanceFromNode: NULL,
314
+ beforeActiveInstanceBlur: noop,
315
+ afterActiveInstanceBlur: noop,
316
+ preparePortalMount: noop,
317
+ prepareScopeUpdate: noop,
318
+ getInstanceFromScope: NULL,
319
+
320
+ // React 19 "suspend on commit" — never suspend.
321
+ requestPostPaintCallback: noop,
322
+ maySuspendCommit: FALSE,
323
+ maySuspendCommitOnUpdate: FALSE,
324
+ maySuspendCommitInSyncRender: FALSE,
325
+ preloadInstance: TRUE,
326
+ startSuspendingCommit: noop,
327
+ suspendInstance: noop,
328
+ suspendOnActiveViewTransition: noop,
329
+ waitForCommitToBeReady: NULL,
330
+ getSuspendedCommitReason: () => 0,
331
+
332
+ // Transitions / forms / console
333
+ NotPendingTransition,
334
+ HostTransitionContext,
335
+ resetFormInstance: noop,
336
+ bindToConsole: (methodName: string, args: unknown[]) =>
337
+ (console as unknown as Record<string, (...a: unknown[]) => void>)[methodName]?.bind(console, ...args) ??
338
+ noop,
339
+
340
+ // Persistence (supportsPersistence: false — destructured, never called)
341
+ createContainerChildSet: () => [],
342
+ appendChildToContainerChildSet: noop,
343
+ finalizeContainerChildren: noop,
344
+ replaceContainerChildren: noop,
345
+ cloneInstance: NULL,
346
+ cloneMutableInstance: (instance: HostInstance) => instance,
347
+ cloneMutableTextInstance: (instance: HostInstance) => instance,
348
+ cloneHiddenInstance: (instance: HostInstance) => instance,
349
+ cloneHiddenTextInstance: (instance: HostInstance) => instance,
350
+
351
+ // Fragment instances (only used for `<Fragment ref={…}>` — unimplemented)
352
+ createFragmentInstance: () => ({}),
353
+ updateFragmentInstanceFiber: noop,
354
+ commitNewChildToFragmentInstance: noop,
355
+ deleteChildFromFragmentInstance: noop,
356
+
357
+ // View transitions (React 19) — all stubs
358
+ createViewTransitionInstance: (name: string) => ({ name, autoName: null }),
359
+ applyViewTransitionName: noop,
360
+ restoreViewTransitionName: noop,
361
+ cancelViewTransitionName: noop,
362
+ cancelRootViewTransitionName: noop,
363
+ restoreRootViewTransitionName: noop,
364
+ cloneRootViewTransitionContainer: noop,
365
+ removeRootViewTransitionClone: noop,
366
+ measureInstance: NULL,
367
+ measureClonedInstance: NULL,
368
+ wasInstanceInViewport: FALSE,
369
+ hasInstanceChanged: FALSE,
370
+ hasInstanceAffectedParent: FALSE,
371
+ startViewTransition: NULL,
372
+ startGestureTransition: NULL,
373
+ stopViewTransition: noop,
374
+ addViewTransitionFinishedListener: noop,
375
+ getCurrentGestureOffset: () => 0,
376
+
377
+ // Hydration (supportsHydration: false — destructured, never called)
378
+ isSuspenseInstancePending: FALSE,
379
+ isSuspenseInstanceFallback: FALSE,
380
+ getSuspenseInstanceFallbackErrorDetails: () => ({}),
381
+ registerSuspenseInstanceRetry: noop,
382
+ getNextHydratableSibling: NULL,
383
+ getNextHydratableSiblingAfterSingleton: NULL,
384
+ getFirstHydratableChild: NULL,
385
+ getFirstHydratableChildWithinContainer: NULL,
386
+ getFirstHydratableChildWithinActivityInstance: NULL,
387
+ getFirstHydratableChildWithinSingleton: NULL,
388
+ getFirstHydratableChildWithinSuspenseInstance: NULL,
389
+ canHydrateInstance: NULL,
390
+ canHydrateTextInstance: NULL,
391
+ canHydrateActivityInstance: NULL,
392
+ canHydrateSuspenseInstance: NULL,
393
+ canHydrateFormStateMarker: NULL,
394
+ isFormStateMarkerMatching: FALSE,
395
+ hydrateInstance: noop,
396
+ hydrateTextInstance: noop,
397
+ hydrateActivityInstance: noop,
398
+ hydrateSuspenseInstance: noop,
399
+ getNextHydratableInstanceAfterActivityInstance: NULL,
400
+ getNextHydratableInstanceAfterSuspenseInstance: NULL,
401
+ commitHydratedInstance: noop,
402
+ commitHydratedContainer: noop,
403
+ commitHydratedSuspenseInstance: noop,
404
+ finalizeHydratedChildren: FALSE,
405
+ flushHydrationEvents: noop,
406
+ clearActivityBoundary: FALSE,
407
+ clearSuspenseBoundary: FALSE,
408
+ clearActivityBoundaryFromContainer: FALSE,
409
+ clearSuspenseBoundaryFromContainer: FALSE,
410
+ hideDehydratedBoundary: noop,
411
+ unhideDehydratedBoundary: noop,
412
+ shouldDeleteUnhydratedTailInstances: FALSE,
413
+ diffHydratedPropsForDevWarnings: NULL,
414
+ diffHydratedTextForDevWarnings: NULL,
415
+ describeHydratableInstanceForDevWarnings: NULL,
416
+ validateHydratableInstance: TRUE,
417
+ validateHydratableTextInstance: TRUE,
418
+
419
+ // Host resources (stylesheets/scripts — supportsResources: false)
420
+ isHostHoistableType: FALSE,
421
+ getHoistableRoot: NULL,
422
+ getResource: NULL,
423
+ acquireResource: noop,
424
+ releaseResource: noop,
425
+ hydrateHoistable: noop,
426
+ mountHoistable: noop,
427
+ unmountHoistable: noop,
428
+ createHoistableInstance: NULL,
429
+ prepareToCommitHoistables: noop,
430
+ mayResourceSuspendCommit: FALSE,
431
+ preloadResource: TRUE,
432
+ suspendResource: noop,
433
+
434
+ // Singletons (supportsSingletons: false)
435
+ resolveSingletonInstance: NULL,
436
+ acquireSingletonInstance: noop,
437
+ releaseSingletonInstance: noop,
438
+ isHostSingletonType: FALSE,
439
+ isSingletonScope: FALSE,
440
+
441
+ // Test selectors (supportsTestSelectors: false)
442
+ findFiberRoot: NULL,
443
+ getBoundingRect: NULL,
444
+ getTextContent: NULL,
445
+ isHiddenSubtree: FALSE,
446
+ matchAccessibilityRole: FALSE,
447
+ setFocusIfFocusable: FALSE,
448
+ setupIntersectionObserver: noop,
449
+ };
package/src/index.tsx CHANGED
@@ -82,8 +82,13 @@ export interface IslandProps<A = string>
82
82
  */
83
83
  worker?: (() => Worker) | URL;
84
84
  client?: IslandClient;
85
- /** Extra pool options — concurrency, taskTimeout, respawn… (poolSize stays 1). */
86
- workerOptions?: IslandWorkerOptions;
85
+ /**
86
+ * Extra pool options — concurrency, taskTimeout, respawn… (poolSize stays
87
+ * 1). `doorbell: false` builds the shorthand client message-only — no
88
+ * SharedArrayBuffer, no COOP/COEP requirement (same as `mode: 'poll'` on
89
+ * a direct `mountIsland` shorthand mount).
90
+ */
91
+ workerOptions?: IslandWorkerOptions & { doorbell?: boolean };
87
92
  /**
88
93
  * Initial flush mode — 'push' (default) drives op replay off the
89
94
  * shared-memory doorbell (needs cross-origin isolation); 'poll' drains on
@@ -272,7 +277,8 @@ export interface IslandShellProps {
272
277
  /** How to reach the worker — see IslandProps.worker/client. */
273
278
  worker?: (() => Worker) | URL;
274
279
  client?: IslandClient;
275
- workerOptions?: IslandWorkerOptions;
280
+ /** See IslandProps.workerOptions — `doorbell` included. */
281
+ workerOptions?: IslandWorkerOptions & { doorbell?: boolean };
276
282
  /** Initial flush mode — see IslandProps.mode. */
277
283
  mode?: Mode;
278
284
  /** Island → shell channel: every `emit` op lands here. */
@@ -323,12 +329,14 @@ const splitProxyProps = (
323
329
 
324
330
  /* ── Shared proxy implementation ─────────────────────────────────────────── */
325
331
 
326
- type LazyModule<A> = A | { default: A } | { app: A; worker?: (() => Worker) | URL };
332
+ type LazyModule<A> = A | { default: A } | { app: A | string; worker?: (() => Worker) | URL };
327
333
 
328
334
  /** Cached state for a lazy loader. One cache entry per loader function. */
329
335
  interface LazyState {
330
336
  promise: Promise<unknown>;
331
- value?: IslandAppLike;
337
+ // Contract modules may carry a registry-key string instead of the app —
338
+ // `Island`'s `app` prop accepts `IslandAppRef` (name or reference).
339
+ value?: IslandAppLike | string;
332
340
  worker?: (() => Worker) | URL;
333
341
  error?: unknown;
334
342
  }
@@ -337,7 +345,7 @@ const lazyCache = new WeakMap<() => Promise<LazyModule<IslandAppLike>>, LazyStat
337
345
 
338
346
  /** Resolve a lazy module through Suspense; reused across re-renders. */
339
347
  function useLazyApp(loader: () => Promise<LazyModule<IslandAppLike>>): {
340
- app?: IslandAppLike;
348
+ app?: IslandAppLike | string;
341
349
  worker?: (() => Worker) | URL;
342
350
  } {
343
351
  let state = lazyCache.get(loader);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * react-reconciler ships no TypeScript types. It is a CommonJS factory —
3
+ * `Reconciler(hostConfig)` returns the reconciler API (createContainer,
4
+ * updateContainer, flushSyncWork, flushSyncFromReconciler, flushPassiveEffects…).
5
+ * The host config surface is documented in hostConfig.ts.
6
+ */
7
+ declare module 'react-reconciler' {
8
+ const createReconciler: (hostConfig: unknown) => {
9
+ createContainer: (...args: unknown[]) => unknown;
10
+ updateContainer: (...args: unknown[]) => unknown;
11
+ updateContainerSync: (...args: unknown[]) => unknown;
12
+ flushSyncWork: () => unknown;
13
+ flushSyncFromReconciler: <T>(fn: () => T) => T;
14
+ flushPassiveEffects: () => boolean;
15
+ [key: string]: unknown;
16
+ };
17
+ export default createReconciler;
18
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The React-runtime half of a React island — `react`, `react-reconciler`
3
+ * and the host config live HERE so a worker bundle whose registry holds only
4
+ * non-React apps (Vue/Svelte/Solid/Angular/imperative) never pulls the
5
+ * reconciler in. `@atolljs/react-island/worker` reaches this module through
6
+ * `reactIslandApp`'s mount, inside the RenderedIslandApp contract.
7
+ */
8
+ import type { ReactElement } from 'react';
9
+ import Reconciler from 'react-reconciler';
10
+ import { hostConfig } from './hostConfig';
11
+ import type { RootContainer } from '@atolljs/islands/worker';
12
+
13
+ /**
14
+ * A mounted React tree — carries its own reconciler + container and the
15
+ * `render`/`unmountTree`/`flush` verbs the adapter maps onto the
16
+ * RenderedHandle contract. Identity (key/app/pid) lives on the core
17
+ * Instance — this object is only the renderer half.
18
+ */
19
+ export interface ReactInstance {
20
+ reconciler: ReturnType<typeof Reconciler>;
21
+ container: unknown;
22
+ /**
23
+ * Commit synchronously around `fn`.
24
+ *
25
+ * In 0.34, `updateContainer`/setState only *schedule* work — the actual
26
+ * render+commit happens when the root scheduler task runs (a macrotask via
27
+ * the `scheduler` package). `flushSyncFromReconciler` pins the update
28
+ * priority to the discrete/sync lane for the duration of `fn` and
29
+ * `flushSyncWork` flushes pending sync work, so by the time it returns the
30
+ * mutation hooks have run and the instance's op queue is full.
31
+ */
32
+ sync(fn: () => void): void;
33
+ /** Schedule a mount/re-render of `el` — call inside `sync`. */
34
+ render(el: ReactElement): void;
35
+ /** Schedule the detach — emits the remove ops; call inside `sync`. */
36
+ unmountTree(): void;
37
+ /** Passive effects + pending sync work — the `flush` task's React branch. */
38
+ flush(): void;
39
+ }
40
+
41
+ /**
42
+ * Legacy root (tag 0) — no concurrent features. Container creation is
43
+ * per-instance, not module-level, so a second mount() can't collide with the
44
+ * first instance's tree.
45
+ */
46
+ export function createReactInstance(instance: string): ReactInstance {
47
+ const reconciler = Reconciler(hostConfig);
48
+ const container = reconciler.createContainer(
49
+ // Instance-stamped root container — createInstance reads `.instance` off it,
50
+ // binding every element to this instance even in out-of-task commits.
51
+ { id: 0, instance } as RootContainer,
52
+ 0,
53
+ null,
54
+ false,
55
+ null,
56
+ '',
57
+ console.error, // onUncaughtError
58
+ console.error, // onCaughtError
59
+ console.error, // onRecoverableError
60
+ null, // onDefaultTransitionIndicator
61
+ );
62
+ return {
63
+ reconciler,
64
+ container,
65
+ sync: (fn) => {
66
+ reconciler.flushSyncFromReconciler(fn);
67
+ reconciler.flushSyncWork();
68
+ },
69
+ render: (el) => reconciler.updateContainer(el, container, null, null),
70
+ unmountTree: () => reconciler.updateContainer(null, container, null, null),
71
+ flush: () => {
72
+ reconciler.flushPassiveEffects();
73
+ reconciler.flushSyncWork();
74
+ },
75
+ };
76
+ }
package/src/slot.tsx ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A transclusion slot — renders a leaf element marked `data-atoll-slot`.
3
+ * The element's BOX belongs to this worker tree (layout/style ops apply as
4
+ * usual); its CONTENTS belong to the shell: when the create op lands on the
5
+ * main thread, the island's `slots[name]` callback gets the real element
6
+ * and can mount anything inside — a canvas, Monaco, a main-thread React
7
+ * root — all real DOM with real events and zero wire traffic.
8
+ *
9
+ * Must stay a LEAF in this tree: if worker-side children were appended here
10
+ * their ops would interleave with shell-owned content. There is also no
11
+ * measurement channel — the island knows the box it laid out, not the
12
+ * pixel size the shell put inside it.
13
+ */
14
+ import type { ReactElement } from 'react';
15
+
16
+ export function Slot({ name, style }: { name: string; style?: Record<string, unknown> }): ReactElement {
17
+ return <div data-atoll-slot={name} style={style} />;
18
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `@atolljs/react-island/worker` — the worker-side half of React
3
+ * islands: `reactIslandApp(Component)` wraps a React component as a islands
4
+ * `RenderedIslandApp`, so it can sit in a `definePolyWorker` `apps`
5
+ * registry beside Vue/Svelte/Solid/Angular and imperative apps:
6
+ *
7
+ * // render.worker.ts — React-only registry
8
+ * import { defineReactPolyWorker } from '@atolljs/react-island/worker';
9
+ * export const renderWorker = defineReactPolyWorker({
10
+ * apps: { controls: ControlsApp, charts: ChartsApp },
11
+ * });
12
+ *
13
+ * // one-app worker
14
+ * import { defineReactMonoWorker } from '@atolljs/react-island/worker';
15
+ * export const worker = defineReactMonoWorker(ChartsApp);
16
+ *
17
+ * // mixed-framework registry — compose the adapters directly
18
+ * import { definePolyWorker } from '@atolljs/islands/worker';
19
+ * import { reactIslandApp } from '@atolljs/react-island/worker';
20
+ * export const renderWorker = definePolyWorker({ apps: {
21
+ * charts: reactIslandApp(ChartsApp),
22
+ * badge: vueIslandApp(VueBadge),
23
+ * }});
24
+ *
25
+ * The reconciler + host config live in `reactInstance.ts`/`hostConfig.ts`:
26
+ * importing THIS module is what pulls `react` + `react-reconciler` into a
27
+ * worker bundle (once, shared across every React app in the registry).
28
+ * `@atolljs/islands/worker` itself stays framework-neutral — non-React
29
+ * registries never see this code.
30
+ */
31
+ import { createElement, type ComponentType } from 'react';
32
+ import type { SharedMemory, WorkerDefinition } from '@atolljs/core';
33
+ import {
34
+ defineMonoWorker,
35
+ definePolyWorker,
36
+ islandApp,
37
+ islandAppNameOf,
38
+ } from '@atolljs/islands/worker';
39
+ import type {
40
+ DoorbellSpec,
41
+ ImperativeIslandApp,
42
+ IslandWorkerMethods,
43
+ RenderContext,
44
+ RenderedHandle,
45
+ RenderedIslandApp,
46
+ } from '@atolljs/islands/worker';
47
+ import { createReactInstance } from './reactInstance';
48
+
49
+ // Worker entries shouldn't need a second package specifier for the
50
+ // island→shell channel — `import { emit } from '@atolljs/react-island/worker'`.
51
+ export { emit, runInInstance } from '@atolljs/islands/worker';
52
+ export { Slot } from './slot';
53
+
54
+ type AnyComponent = ComponentType<Record<string, unknown>>;
55
+
56
+ /**
57
+ * Wrap a React component as a `RenderedIslandApp` — `mount` creates the
58
+ * instance's reconciler + container and renders the initial tree inside the
59
+ * sync lane; `update` re-renders with new props (the reconciler diffs, only
60
+ * changed ops emit); `sync` is the dispatch-time commit pin; `dispose`
61
+ * schedules the unmount; `flush` drains passive effects + pending sync work
62
+ * for the `flush` task.
63
+ *
64
+ * A stamped component (`islandApp('name', Comp)`) keeps its stamp through
65
+ * the wrap, so registry warn-checks and `defineMonoWorker` name resolution
66
+ * still see the shell-facing name.
67
+ */
68
+ export function reactIslandApp(App: AnyComponent): RenderedIslandApp {
69
+ const app: RenderedIslandApp = {
70
+ mount({ instance, props }: RenderContext): RenderedHandle {
71
+ const react = createReactInstance(instance);
72
+ const render = (next: Record<string, unknown>): void =>
73
+ react.sync(() => react.render(createElement(App, next)));
74
+ render(props);
75
+ return {
76
+ update: render,
77
+ sync: (fn) => react.sync(fn),
78
+ dispose: () => react.sync(() => react.unmountTree()),
79
+ flush: () => react.flush(),
80
+ };
81
+ },
82
+ };
83
+ const stamp = islandAppNameOf(App);
84
+ if (stamp !== undefined) (app as { islandAppName?: string }).islandAppName = stamp;
85
+ return app;
86
+ }
87
+
88
+ /** Stamp + wrap in one step — `reactIsland('charts', ChartsApp)` yields the
89
+ * registry value AND the name the shell mounts by. */
90
+ export const reactIsland = (
91
+ name: string,
92
+ App: AnyComponent,
93
+ ): RenderedIslandApp & { readonly islandAppName: string } =>
94
+ islandApp(name, reactIslandApp(App));
95
+
96
+ export interface ReactPolyWorkerRegistry {
97
+ /**
98
+ * Name → React component (or `{ imperative }` app) registry, mirroring
99
+ * `definePolyWorker({ apps })`. Components pass through `reactIslandApp`;
100
+ * imperative defs and already-wrapped apps (e.g. `reactIsland()` results)
101
+ * register as-is.
102
+ */
103
+ apps: Record<string, AnyComponent | ImperativeIslandApp | RenderedIslandApp>;
104
+ /** Doorbell contract override — forwarded to `definePolyWorker`. */
105
+ sharedMemory?: SharedMemory<DoorbellSpec>;
106
+ }
107
+
108
+ /**
109
+ * `definePolyWorker` for React apps — maps each component in the registry
110
+ * through `reactIslandApp` and delegates. One worker, many React islands;
111
+ * for a mixed-framework registry call `definePolyWorker` with the per-app
112
+ * adapters instead.
113
+ */
114
+ export function defineReactPolyWorker(
115
+ registry: ReactPolyWorkerRegistry,
116
+ options?: { sharedMemory?: SharedMemory<DoorbellSpec> },
117
+ ): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
118
+ const apps: Record<string, RenderedIslandApp | ImperativeIslandApp> = {};
119
+ for (const [key, app] of Object.entries(registry.apps)) {
120
+ apps[key] = typeof app === 'function' ? reactIslandApp(app) : app;
121
+ }
122
+ return definePolyWorker({
123
+ apps,
124
+ sharedMemory: registry.sharedMemory ?? options?.sharedMemory,
125
+ });
126
+ }
127
+
128
+ /**
129
+ * `defineMonoWorker` for React apps — one worker pinned to a single
130
+ * component (or `{ imperative }` app), the isolated-bundle host shape.
131
+ */
132
+ export function defineReactMonoWorker(
133
+ app: AnyComponent | ImperativeIslandApp | RenderedIslandApp,
134
+ options?: { sharedMemory?: SharedMemory<DoorbellSpec> },
135
+ ): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
136
+ return typeof app === 'function'
137
+ ? defineMonoWorker(reactIslandApp(app), options)
138
+ : defineMonoWorker(app, options);
139
+ }