@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 +58 -65
- package/package.json +19 -6
- package/src/hostConfig.ts +449 -0
- package/src/index.tsx +14 -6
- package/src/react-reconciler.d.ts +18 -0
- package/src/reactInstance.ts +76 -0
- package/src/slot.tsx +18 -0
- package/src/worker.ts +139 -0
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 }
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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`
|
|
67
|
-
|
|
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:
|
|
60
|
+
import type { TableProps } from './worker/apps'; // type-only: zero bundle cost
|
|
75
61
|
|
|
76
|
-
//
|
|
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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
82
|
+
## Worker topologies
|
|
105
83
|
|
|
106
|
-
|
|
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
|
-
|
|
89
|
+
import { defineReactPolyWorker } from '@atolljs/react-island/worker';
|
|
90
|
+
export const renderWorker = defineReactPolyWorker({ apps: { charts: ChartsApp, table: TableApp } });
|
|
111
91
|
|
|
112
|
-
// map.worker.ts —
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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.
|
|
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.
|
|
17
|
-
"@atolljs/islands": "0.1.
|
|
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
|
-
/**
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|