@atolljs/islands 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 +94 -285
- package/package.json +8 -5
- package/src/app.ts +7 -5
- package/src/island.ts +64 -9
- package/src/metrics.ts +79 -0
- package/src/worker/defineWorkers.ts +140 -180
- package/src/worker/dom/document.ts +13 -0
- package/src/worker/index.ts +12 -11
- package/src/worker/instance.ts +104 -57
- package/src/worker/proxyDom.ts +2 -0
- package/src/react-reconciler.d.ts +0 -18
- package/src/worker/hostConfig.ts +0 -446
- package/src/worker/reactInstance.ts +0 -74
- package/src/worker/slot.tsx +0 -18
package/README.md
CHANGED
|
@@ -1,38 +1,49 @@
|
|
|
1
1
|
# @atolljs/islands
|
|
2
2
|
|
|
3
3
|
Framework islands inside Atoll workers — opt-in DOM rendering off the main
|
|
4
|
-
thread.
|
|
4
|
+
thread. A real framework renderer commits against a DOM-free host surface in
|
|
5
|
+
the worker; every commit serializes to an op stream the main thread replays as
|
|
6
|
+
DOM mutations.
|
|
7
|
+
|
|
8
|
+
**[Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/quickstart/)**
|
|
9
|
+
|
|
10
|
+
The vocabulary:
|
|
5
11
|
|
|
6
|
-
- **Atoll** — the fabric: worker pools, contracts, shared memory, task dispatch
|
|
7
|
-
(`@atolljs/core`).
|
|
8
12
|
- **PolyWorker** — one worker hosting a REGISTRY of islands
|
|
9
|
-
(`definePolyWorker({ apps })`).
|
|
10
|
-
|
|
13
|
+
(`definePolyWorker({ apps })`). Several islands share one module graph, one
|
|
14
|
+
framework runtime, one op pump — the bundle-optimization shape.
|
|
11
15
|
- **MonoWorker** — one worker pinned to a single island app
|
|
12
|
-
(`defineMonoWorker(app)`).
|
|
13
|
-
|
|
14
|
-
- **Island** —
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
(`defineMonoWorker(app)`). Own bundle, own failure domain — the isolation
|
|
17
|
+
shape.
|
|
18
|
+
- **Island** — one mounted instance of a registered app. Each gets a `app@N`
|
|
19
|
+
**instance key** that scopes its op queue, document, and events.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
```bash
|
|
24
|
+
npm install @atolljs/core @atolljs/islands # framework-neutral core
|
|
25
|
+
npm install @atolljs/react-island # + a shell binding for your framework
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Shell bindings (main-thread components + per-framework worker renderers):
|
|
29
|
+
[`@atolljs/react-island`](../react-island),
|
|
30
|
+
[`@atolljs/vue-island`](../vue-island),
|
|
31
|
+
[`@atolljs/svelte-island`](../svelte-island),
|
|
32
|
+
[`@atolljs/solid-island`](../solid-island),
|
|
33
|
+
[`@atolljs/angular-island`](../angular-island).
|
|
34
|
+
A registry worker can mix apps from several frameworks — plus imperative
|
|
35
|
+
proxy-DOM apps — freely.
|
|
26
36
|
|
|
27
37
|
## Quickstart
|
|
28
38
|
|
|
29
39
|
```ts
|
|
30
40
|
// render.worker.ts — the whole worker entry
|
|
31
41
|
import { definePolyWorker } from '@atolljs/islands/worker';
|
|
42
|
+
import { reactIslandApp } from '@atolljs/react-island/worker';
|
|
32
43
|
|
|
33
44
|
export const renderWorker = definePolyWorker({
|
|
34
45
|
apps: {
|
|
35
|
-
dashboard: DashboardApp,
|
|
46
|
+
dashboard: reactIslandApp(DashboardApp), // a React component
|
|
36
47
|
vanilla: { imperative: (doc, props) => { ... } } // or pure proxy-DOM code
|
|
37
48
|
},
|
|
38
49
|
});
|
|
@@ -55,137 +66,69 @@ island.destroy();
|
|
|
55
66
|
```
|
|
56
67
|
|
|
57
68
|
Pass `client: connectIslandWorker({ worker })` instead of `worker` when
|
|
58
|
-
several islands should share one worker (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
The `worker` option is a factory — the bundler must see the worker script as
|
|
64
|
-
an entry, which is where setups diverge:
|
|
65
|
-
|
|
66
|
-
- **Vite / webpack 5**: `() => new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' })` — the `new URL` literal is what the bundler detects; don't hoist or compute it.
|
|
67
|
-
- **Vite alternative**: `import XWorker from './x.worker.ts?worker'` then `worker: () => new XWorker()`.
|
|
68
|
-
- **esbuild / others**: the URL pattern needs the bundler's worker handling (esbuild bundles it only in `format: 'esm'` builds); otherwise compile the worker entry separately and pass `worker: new URL('/assets/x.worker.js', import.meta.url)`-style resolved URLs.
|
|
69
|
-
|
|
70
|
-
A path the bundler didn't resolve shows up as a `mountIsland` `mountTimeout`
|
|
71
|
-
rejection naming the unanswered entry — not a silent hang.
|
|
72
|
-
|
|
73
|
-
## React shells: `@atolljs/react-island`
|
|
74
|
-
|
|
75
|
-
When the *shell* itself is a React app, the companion package
|
|
76
|
-
[`@atolljs/react-island`](../react-island) wraps these calls in
|
|
77
|
-
components — `<Island/>` for the declarative `mountIsland`, and the
|
|
78
|
-
`islandComponent`/`lazyIsland` proxies that make a worker app look and type
|
|
79
|
-
like a local component (Suspense on the module load, `fallback` prop on the
|
|
80
|
-
mount). This package stays React-free on the shell side by design — the
|
|
81
|
-
component layer lives there.
|
|
82
|
-
|
|
83
|
-
App contracts still live here: `islandApp(name, app)` stamps an app with its
|
|
84
|
-
registry name (a data property — minification-proof, unlike `fn.name`) so a
|
|
85
|
-
shell-side component reference resolves to the wire key, and
|
|
86
|
-
`definePolyWorker` warns if a stamp and its registry key drift apart.
|
|
87
|
-
`IslandAppProps<A>` infers a reference's props type from its signature.
|
|
88
|
-
|
|
89
|
-
## Framework worker renderers
|
|
90
|
-
|
|
91
|
-
The third app kind: `RenderedIslandApp` — `{ mount({instance, doc, props}) →
|
|
92
|
-
{ update?, dispose? } }`. The app renders into the instance's proxy document
|
|
93
|
-
with its own native renderer; every mutation emits the same op stream, and
|
|
94
|
-
`update`/`dispose` give the framework fine-grained `updateProps` and clean
|
|
95
|
-
teardown instead of the imperative clear-and-rebuild. Framework bindings
|
|
96
|
-
ship as `*-island` packages — each re-exports `definePolyWorker`/`emit`
|
|
97
|
-
so the worker entry needs no direct islands import:
|
|
69
|
+
several islands should share one worker (the client is released when its last
|
|
70
|
+
island destroys). Framework apps ship via the `*-island` packages — each
|
|
71
|
+
re-exports `definePolyWorker`/`emit` so the worker entry needs no direct
|
|
72
|
+
islands import:
|
|
98
73
|
|
|
99
74
|
```ts
|
|
100
|
-
|
|
101
|
-
// svelte: import { defineSveltePolyWorker } from '@atolljs/svelte-island/worker'
|
|
102
|
-
// solid: import { defineSolidPolyWorker } from '@atolljs/solid-island/worker'
|
|
103
|
-
// angular: import { defineAngularPolyWorker } from '@atolljs/angular-island/worker'
|
|
75
|
+
import { defineVuePolyWorker } from '@atolljs/vue-island/worker';
|
|
104
76
|
export const worker = defineVuePolyWorker({ apps: { counter: Counter } });
|
|
77
|
+
// also: defineReactPolyWorker, defineSveltePolyWorker,
|
|
78
|
+
// defineSolidPolyWorker, defineAngularPolyWorker
|
|
105
79
|
```
|
|
106
80
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
| `@atolljs/svelte-island` | Svelte 5 `mount()` into `doc.body`, `$state` props box | fine-grained | Compiled components only (vite-plugin-svelte); `emit`/`runInInstance` re-exported |
|
|
111
|
-
| `@atolljs/solid-island` | `solid-js/universal` `createRenderer` | fine-grained (per-key signal props) | **Pin the client build** — `worker`/`node` resolve conditions pick the SSR build; the adapter probes and throws if it happens anyway. JSX needs `babel-preset-solid` `{generate:'universal'}` (see its README) |
|
|
112
|
-
| `@atolljs/angular-island` | `RendererFactory2`/`Renderer2` → proxy DOM + `createComponent`, zoneless | `setInput` + manual CD | JIT components need `import '@angular/compiler'` in the worker entry (missing it throws a named error); AOT/`ɵcmp` components need nothing |
|
|
113
|
-
|
|
114
|
-
All four speak the same protocol — a registry worker can mix React,
|
|
115
|
-
Vue, Svelte, Solid, Angular, and imperative apps freely.
|
|
116
|
-
|
|
117
|
-
## Bundle composition — what's in a worker bundle
|
|
118
|
-
|
|
119
|
-
The `/worker` entry is framework-neutral: `definePolyWorker`, the proxy
|
|
120
|
-
DOM, the op protocol, event dispatch, `emit`/`callbackProp`, and instance
|
|
121
|
-
lifecycle. Framework runtimes arrive only through the binding you import:
|
|
122
|
-
|
|
123
|
-
- **React islands** — `react` + `react-reconciler` + the host config live in
|
|
124
|
-
`worker/reactInstance.ts`, reached via `await import()` the first time a
|
|
125
|
-
registered app resolves to a React component. A worker whose registry
|
|
126
|
-
holds only Vue/Svelte/Solid/Angular/imperative apps never loads it —
|
|
127
|
-
bundlers put it in a lazily-fetched chunk (or drop it entirely when the
|
|
128
|
-
worker entry can't reach it statically).
|
|
129
|
-
- **Vue/Svelte/Solid/Angular islands** — each `-island` package statically
|
|
130
|
-
imports only its own framework. A `defineVuePolyWorker` entry bundles
|
|
131
|
-
islands core + Vue, and no other framework.
|
|
132
|
-
- **Imperative-only** — islands core alone (plus `htmlparser2`, which
|
|
133
|
-
the proxy DOM's `innerHTML`/`insertStaticContent` need).
|
|
134
|
-
|
|
135
|
-
Two caveats: the `Slot` helper is a React component (its JSX pulls in
|
|
136
|
-
`react/jsx-runtime` — a few KB, tree-shaken when unused), and
|
|
137
|
-
`definePolyWorker`'s mount task is `async` because a React first-mount
|
|
138
|
-
awaits the runtime chunk — callers already `await` it, so this is only a
|
|
139
|
-
typing-level detail.
|
|
81
|
+
Stamp apps with `islandApp(name, app)` so a shell-side component reference
|
|
82
|
+
resolves to its registry key — the stamp is a data property, minification-proof
|
|
83
|
+
(unlike `fn.name`).
|
|
140
84
|
|
|
141
|
-
##
|
|
142
|
-
|
|
143
|
-
A renderer adapter is a `RenderedIslandApp` — one method:
|
|
85
|
+
## Worker entries per bundler
|
|
144
86
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
mount(ctx: { instance: string; doc: ProxyDocument; props: Record<string, unknown> }):
|
|
148
|
-
{ update?(props): void; dispose?(): void } | void;
|
|
149
|
-
}
|
|
150
|
-
```
|
|
87
|
+
The `worker` option is a factory — the bundler must see the worker script as
|
|
88
|
+
an entry:
|
|
151
89
|
|
|
152
|
-
`
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
`insertBefore`/`appendChild`/`removeChild` (fragments splice their children
|
|
156
|
-
in), `setAttribute`/`setAttributeNS`, `textContent`, `innerHTML`,
|
|
157
|
-
`cloneNode`, `template.content`, and `addEventListener` (`{once, passive,
|
|
158
|
-
capture}` options cross the wire). Comments are real anchors — frameworks
|
|
159
|
-
placing branch markers (Vue `v-if`, Svelte `{#if}`) should use
|
|
160
|
-
`doc.createComment`, not empty text nodes.
|
|
90
|
+
- **Vite / webpack 5**: `() => new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' })` — the `new URL` literal is what the bundler detects; don't hoist or compute it.
|
|
91
|
+
- **Vite alternative**: `import XWorker from './x.worker.ts?worker'` then `worker: () => new XWorker()`.
|
|
92
|
+
- **esbuild / others**: the URL pattern needs the bundler's worker handling; otherwise compile the worker entry separately and pass a resolved URL.
|
|
161
93
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
active instance — resolve via `getActiveInstance() || getLastActiveInstance()` and
|
|
165
|
-
prefer the captured `ctx.instance`/`ctx.doc` over ambient lookup. Work the
|
|
166
|
-
renderer kicks off from timers or promise continuations must re-enter with
|
|
167
|
-
`runInInstance(instance, fn)` — with several islands on one shared client the
|
|
168
|
-
last-active fallback can resolve the *sibling* instance, so treat ambient
|
|
169
|
-
resolution as a convenience, not a contract. After mutations made outside a
|
|
170
|
-
dispatch task, `bumpOpsVersion()` rings the push-mode doorbell so the
|
|
171
|
-
driver flushes the queued ops.
|
|
94
|
+
A path the bundler didn't resolve surfaces as a `mountIsland` `mountTimeout`
|
|
95
|
+
rejection naming the unanswered entry — not a silent hang.
|
|
172
96
|
|
|
173
|
-
|
|
174
|
-
Omit it and `updateProps` degrades to imperative semantics (dispose +
|
|
175
|
-
fresh document + remount). `dispose()` runs inside the instance before its
|
|
176
|
-
proxy document is torn down — unmount framework roots, stop effects.
|
|
97
|
+
## Island rules
|
|
177
98
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
`
|
|
181
|
-
|
|
182
|
-
|
|
99
|
+
- **Pool size is pinned to 1.** One tree lives in one worker's memory — scale
|
|
100
|
+
out with more islands, not wider pools.
|
|
101
|
+
- **Instance keys** are `app` or `app@N`; `mountIsland` mints them and the
|
|
102
|
+
same app can mount in many islands at once.
|
|
103
|
+
- **Clients can be shared.** Several `mountIsland`s into one
|
|
104
|
+
`connectIslandWorker` co-locate their instances in one worker; `destroy()`
|
|
105
|
+
unmounts just that instance and the worker terminates when its last island
|
|
106
|
+
leaves.
|
|
107
|
+
- **Async commits flush themselves.** Push mode (the default) subscribes the
|
|
108
|
+
shared-memory doorbell — `useEffect` commits, timers, and scheduler
|
|
109
|
+
post-task work arrive with no ritual. `mountIsland({ mode: 'poll' })` needs
|
|
110
|
+
no `SharedArrayBuffer`, so no COOP/COEP cross-origin isolation.
|
|
111
|
+
- **`emit(name, payload)`** is the island→shell channel;
|
|
112
|
+
**`callbackProp(fn)`** is the shell→worker half — pass it in props and the
|
|
113
|
+
worker receives a callable (fire-and-forget).
|
|
114
|
+
- **Slots** — a worker-side `<Slot name="x"/>` (React islands:
|
|
115
|
+
`@atolljs/react-island/worker`) renders a `data-atoll-slot` leaf the shell
|
|
116
|
+
fills with real main-thread DOM.
|
|
117
|
+
- **The proxy DOM is write-path-plus-container-geometry.** Shadow-tree reads
|
|
118
|
+
work; only the island container is measured (pushed via `ResizeObserver`).
|
|
119
|
+
Everything else returns honest 0/empty.
|
|
120
|
+
- **Worker-initiated DOM work outside a task** must re-enter via
|
|
121
|
+
`runInInstance(instance, fn)` + `bumpOpsVersion()` — ambient
|
|
122
|
+
`document`/`window` resolution is a heuristic, not a contract.
|
|
123
|
+
- **`preventDefault` can never work** — the real event already dispatched on
|
|
124
|
+
the main thread. Keep high-frequency input (`pointermove`, per-keystroke)
|
|
125
|
+
on the main thread; slots exist for exactly this.
|
|
183
126
|
|
|
184
127
|
## Testing islands in-process
|
|
185
128
|
|
|
186
|
-
`@atolljs/core/testing/inProcessWorker` ships a `Worker` test double
|
|
187
|
-
|
|
188
|
-
|
|
129
|
+
`@atolljs/core/testing/inProcessWorker` ships a `Worker` test double that runs
|
|
130
|
+
the whole protocol in-process — real task registry, real op stream, real
|
|
131
|
+
shared-memory binding; only the thread boundary is faked:
|
|
189
132
|
|
|
190
133
|
```ts
|
|
191
134
|
import { InProcessWorker } from '@atolljs/core/testing/inProcessWorker';
|
|
@@ -195,152 +138,18 @@ InProcessWorker.handlerModules = [() => import('./my.worker')];
|
|
|
195
138
|
const island = await mountIsland({ worker: () => new Worker(url, { type: 'module' }), ... });
|
|
196
139
|
```
|
|
197
140
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
single-registered-app worker resolves its sole app regardless of the
|
|
214
|
-
requested name.
|
|
215
|
-
- **Instance keys** are `app` or `app@N`; `mountIsland` mints them, the same app
|
|
216
|
-
can mount in many islands at once, and `mount`/`updateProps`/`dispatch`/
|
|
217
|
-
`flush`/`unmount`/`whoami` all take the instance first.
|
|
218
|
-
- **Clients can be shared.** Several `mountIsland`s into ONE
|
|
219
|
-
`connectIslandWorker` co-locate their instances in one worker (multi-island-
|
|
220
|
-
per-worker). `destroy()` unmounts just that instance via `unmount`; the
|
|
221
|
-
worker terminates when its last island leaves.
|
|
222
|
-
- **Async commits flush themselves.** Push mode (the default) subscribes the
|
|
223
|
-
shared-memory doorbell when the mount handshake lands — `useEffect` commits,
|
|
224
|
-
timers, and framework schedulers' post-task work arrive with no
|
|
225
|
-
`setMode`/`flush` ritual. `mountIsland({ mode: 'poll' })` drains on a 50ms
|
|
226
|
-
interval instead, and with the `worker` shorthand builds a doorbell-free
|
|
227
|
-
client — no SharedArrayBuffer, so no COOP/COEP cross-origin isolation
|
|
228
|
-
(`connectIslandWorker({ doorbell: false })` does the same for shared
|
|
229
|
-
clients). `handle.setMode` still switches modes after mount.
|
|
230
|
-
- **`mountTimeout`** (default 15s, `0` disables) bounds the mount handshake —
|
|
231
|
-
a worker entry that loads but never answers rejects with a named error
|
|
232
|
-
instead of pending forever; hard failures (module-load errors, crashes)
|
|
233
|
-
reject immediately regardless. A failed mount releases the instance and
|
|
234
|
-
terminates an island-owned client.
|
|
235
|
-
- **`e.target.value` / `e.target.checked` work in handlers** — the dispatched
|
|
236
|
-
form state is stamped onto the proxy event target, so uncontrolled-input
|
|
237
|
-
handlers read the DOM idiom naturally.
|
|
238
|
-
- **`emit(name, payload)`** is the island→shell channel — call inside handlers
|
|
239
|
-
or commit-phase effects while a task holds the instance. From a library
|
|
240
|
-
callback that fires on a timer or promise (no instance active), wrap it:
|
|
241
|
-
`runInInstance(instance, () => { emit(...); bumpOpsVersion(); })` — Leaflet's
|
|
242
|
-
`zoomend` in the demo does exactly this.
|
|
243
|
-
- **`callbackProp(fn)`** is the shell→worker half: pass it in props
|
|
244
|
-
(`props: { onSave: callbackProp(fn) }`) and the worker receives a callable
|
|
245
|
-
— invoking it fires `fn(...args)` on the shell, marshalled through the
|
|
246
|
-
emit channel. Fire-and-forget: the callable returns `undefined` (there's
|
|
247
|
-
no await channel), and calls made outside a dispatch task flush via the
|
|
248
|
-
doorbell. Markers work nested inside prop objects/arrays too.
|
|
249
|
-
- **Slots** — `<Slot name="x"/>` renders a leaf `data-atoll-slot` element whose
|
|
250
|
-
contents the shell fills with real main-thread DOM.
|
|
251
|
-
- **The proxy DOM is write-path-plus-container-geometry.** Shadow-tree reads
|
|
252
|
-
work (children, querySelector, innerHTML); so does ONE measured box — the
|
|
253
|
-
driver's `ResizeObserver` pushes the island container's size into the instance
|
|
254
|
-
(`setSize`), and `doc.body`/`documentElement`/elements marked
|
|
255
|
-
`doc.markContainer(el)` report it from `clientWidth`/`offsetWidth`/
|
|
256
|
-
`getBoundingClientRect`. Everything else returns honest 0/empty and warns
|
|
257
|
-
once. `doc.onResize(cb)` re-fires on each push (Leaflet uses it for
|
|
258
|
-
`map.invalidateSize()`).
|
|
259
|
-
- **Event payloads are spec-shaped for pointer events.** clientX/Y, screenX/Y,
|
|
260
|
-
button, `which` (button+1), modifiers, wheel deltaX/Y/deltaMode, pointerType,
|
|
261
|
-
scrollTop, and `targetId` (resolved to a proxy node as `payload.target`) all
|
|
262
|
-
cross. Pointer-family events always carry *numeric* coords — absent fields
|
|
263
|
-
normalize to 0 rather than leaking `undefined` into library math. At dispatch
|
|
264
|
-
time `target`/`currentTarget` materialize as proxy nodes (`currentTarget` =
|
|
265
|
-
the node the handler was attached to, the instance root for id-0 listeners) so
|
|
266
|
-
library middleware can read them — geometry still returns honest zeros.
|
|
267
|
-
- **`installDomShim(doc)`** puts the proxy doc on `globalThis.document` plus a
|
|
268
|
-
`window` facade — `innerHTML` parses via htmlparser2, `document`/`window`
|
|
269
|
-
listeners land on the island container (id 0) so delegation works,
|
|
270
|
-
`window.innerWidth/innerHeight` reflect the pushed size, and
|
|
271
|
-
`globalThis.Element` becomes `ProxyElement` for `instanceof` checks.
|
|
272
|
-
`globalThis.addEventListener` and `self` are never touched — the pool's
|
|
273
|
-
message channel lives there. `uninstall()` (or `doc.dispose()`) restores the
|
|
274
|
-
globals.
|
|
275
|
-
- **Instance-aware globals.** `definePolyWorker` installs a dispatcher so
|
|
276
|
-
`document`/`window`/`Element` are *accessors*, not fixed globals: inside a
|
|
277
|
-
instance task they resolve that instance's document (its facade, `ProxyElement`),
|
|
278
|
-
outside tasks they resolve the sole or last-active instance's, and explicit
|
|
279
|
-
assignments (`installDomShim`, test harnesses) take precedence over implicit
|
|
280
|
-
resolution. React instances get a working `document`/`window` for free —
|
|
281
|
-
libraries that read them during render or in deferred callbacks just work.
|
|
282
|
-
On the main-thread driver, element checks are structural (`nodeType`), never
|
|
283
|
-
`instanceof Element` — the same global may be a proxy in in-process setups.
|
|
284
|
-
Last-active resolution is a heuristic: on a shared client whose islands run
|
|
285
|
-
interleaved async work it can resolve the *sibling* instance — the contract
|
|
286
|
-
for worker-initiated work is `runInInstance` (see "Writing a worker
|
|
287
|
-
renderer").
|
|
288
|
-
- **SVG + portals + library refs.** Ops carry a namespace (`create` gets `ns`)
|
|
289
|
-
and host context tracks `<svg>`/`<foreignObject>` boundaries — the driver
|
|
290
|
-
uses `createElementNS`, so `<svg>` trees land correctly, including through
|
|
291
|
-
portals (the container's namespace propagates). `getPublicInstance` exposes
|
|
292
|
-
`ProxyElement` facades, which are valid react-dom `createPortal` containers —
|
|
293
|
-
this is what makes recharts' tooltip/legend portals work unmodified.
|
|
294
|
-
|
|
295
|
-
## Real libraries — what works
|
|
296
|
-
|
|
297
|
-
**Imperative libraries.** The `map` island in `examples/react-dom-worker` runs
|
|
298
|
-
**Leaflet 1.9, unmodified from npm**, entirely worker-side: `installDomShim(doc)`
|
|
299
|
-
then `await import('leaflet')` (dynamic import is required — Leaflet reads
|
|
300
|
-
`document`/`window` at module scope for Browser detection). Verified working:
|
|
301
|
-
tile `<img>` ops, divIcon markers, controls, attribution, drag-pan
|
|
302
|
-
(document-level listeners registered mid-gesture), wheel zoom, delegated
|
|
303
|
-
marker clicks, and `doc.onResize` → `invalidateSize()`.
|
|
304
|
-
|
|
305
|
-
**React libraries.** The `charts` island runs **recharts 3.x, unmodified**, as
|
|
306
|
-
an ordinary React tree in the worker: `ComposedChart` with grid/axes/tooltip/
|
|
307
|
-
legend/bar/line, `onClick` handlers, and `emit` — verified end-to-end with a
|
|
308
|
-
bar-click round-trip. It exercises the general machinery above: namespaced
|
|
309
|
-
SVG ops, `ProxyElement` refs as portal targets (tooltip/legend render via
|
|
310
|
-
react-dom `createPortal`), instance-resolved `document`/`window` for its
|
|
311
|
-
selector/`getComputedStyle` calls, and `currentTarget` synthesis for its
|
|
312
|
-
mouse middleware. Use fixed chart dimensions — `ResponsiveContainer` has no
|
|
313
|
-
layout to observe — and prefer `isAnimationActive={false}` to skip
|
|
314
|
-
measurement-feedback render passes that converge but cost op volume.
|
|
315
|
-
|
|
316
|
-
Known limits for DOM-heavy libraries:
|
|
317
|
-
|
|
318
|
-
- **Geometry is one box.** Only the island container is measured (pushed);
|
|
319
|
-
libraries that measure arbitrary elements still get 0 — SVG-specific reads
|
|
320
|
-
(`getBBox`, `getTotalLength`, `getComputedTextLength`) too, which is why
|
|
321
|
-
recharts text truncation/animation sizing degrades to stub values.
|
|
322
|
-
- **`preventDefault`/`stopPropagation` are no-ops** — the real event already
|
|
323
|
-
dispatched on the main thread; the worker can't cancel it.
|
|
324
|
-
- **Async-library callbacks that mutate DOM or emit outside a task** must
|
|
325
|
-
re-enter via `runInInstance(instance, fn)` — timers and promise continuations
|
|
326
|
-
have no active instance.
|
|
327
|
-
- **`getContext('2d'/'webgl')` is out of scope** for the op protocol —
|
|
328
|
-
canvas-based libraries belong on `OffscreenCanvas`, which is a different
|
|
329
|
-
transport.
|
|
330
|
-
|
|
331
|
-
## Workload fit
|
|
332
|
-
|
|
333
|
-
Every event costs one postMessage round trip — `pointermove`, per-keystroke
|
|
334
|
-
input, and scroll handlers re-render on worker latency, and `preventDefault`
|
|
335
|
-
can't work because the real event already dispatched. Keep high-frequency
|
|
336
|
-
input on the main thread (slots exist for exactly this) and put coarse
|
|
337
|
-
interactions — clicks, toggles, form submits — behind the island. On the
|
|
338
|
-
other side of the ledger, main-thread replay scales with op *count*, not
|
|
339
|
-
tree size: fine-grained updates replay in ~1ms while whole-tree rebuilds
|
|
340
|
-
cost an order of magnitude more per frame. `mountIsland`'s `onOps` hook
|
|
341
|
-
reports the worker/main split if you want to measure a workload before
|
|
342
|
-
committing it to an island.
|
|
343
|
-
|
|
344
|
-
Peer deps `react`/`react-reconciler` are required even for imperative-only
|
|
345
|
-
consumers — the package *is* the React-rendering pattern; tree-shaking drops
|
|
346
|
-
the reconciler if you never call `definePolyWorker` with React apps.
|
|
141
|
+
## Documentation
|
|
142
|
+
|
|
143
|
+
- [Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/quickstart/)
|
|
144
|
+
- [Worker islands](https://jwhenry3.github.io/atolljs/consumer/islands/) —
|
|
145
|
+
mountIsland options, `IslandHandle`, transport modes
|
|
146
|
+
- [Writing island apps](https://jwhenry3.github.io/atolljs/consumer/island-apps/) —
|
|
147
|
+
emit/callbackProp/slots, instance discipline
|
|
148
|
+
- In-repo internals: [`docs/islands.md`](../../docs/islands.md),
|
|
149
|
+
[`docs/islands-worker.md`](../../docs/islands-worker.md),
|
|
150
|
+
[`docs/islands-frameworks.md`](../../docs/islands-frameworks.md) — proxy-DOM
|
|
151
|
+
surface, worker renderers, bundle composition, real-library limits
|
|
152
|
+
|
|
153
|
+
`@atolljs/islands` itself is framework-free — `react`/`react-reconciler` are
|
|
154
|
+
peer deps of `@atolljs/react-island` only (its `/worker` entry is the React
|
|
155
|
+
renderer), so imperative-only and non-React consumers install zero React.
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atolljs/islands",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Atoll islands — MonoWorker/PolyWorker hosts rendering framework islands (React/Vue/Svelte/Solid/Angular) over a proxy-DOM op protocol",
|
|
5
|
+
"homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"exports": {
|
|
7
8
|
".": "./src/index.ts",
|
|
@@ -11,19 +12,21 @@
|
|
|
11
12
|
"src"
|
|
12
13
|
],
|
|
13
14
|
"publishConfig": {
|
|
14
|
-
"access": "public"
|
|
15
|
+
"access": "public",
|
|
16
|
+
"registry": "https://registry.npmjs.org"
|
|
15
17
|
},
|
|
16
18
|
"dependencies": {
|
|
17
19
|
"htmlparser2": "^12.0.0"
|
|
18
20
|
},
|
|
19
21
|
"peerDependencies": {
|
|
20
|
-
"@atolljs/core": "0.1.
|
|
21
|
-
"react": "^19.3.0",
|
|
22
|
-
"react-reconciler": "^0.34.0"
|
|
22
|
+
"@atolljs/core": "0.1.2"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
26
26
|
"url": "https://github.com/jwhenry3/atolljs",
|
|
27
27
|
"directory": "packages/islands"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/jwhenry3/atolljs/issues"
|
|
28
31
|
}
|
|
29
32
|
}
|
package/src/app.ts
CHANGED
|
@@ -71,11 +71,13 @@ export type IslandAppProps<A> = A extends {
|
|
|
71
71
|
: Record<string, unknown>;
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* The structural shape of a mountable app — `never` params so ANY component
|
|
75
|
-
*
|
|
76
|
-
* `
|
|
77
|
-
*
|
|
74
|
+
* The structural shape of a mountable app — `never` params so ANY component,
|
|
75
|
+
* imperative def, or framework-adapter output (`{ mount }` — what
|
|
76
|
+
* `reactIslandApp`/`vueIslandApp`/`reactIsland` produce) is assignable.
|
|
77
|
+
* Kept worker-free: importing the real `IslandApp` union from
|
|
78
|
+
* `worker/defineWorkers` would drag worker code into shell bundles.
|
|
78
79
|
*/
|
|
79
80
|
export type IslandAppLike =
|
|
80
81
|
| ((props: never) => unknown)
|
|
81
|
-
| { imperative: (doc: never, props: never) => void }
|
|
82
|
+
| { imperative: (doc: never, props: never) => void }
|
|
83
|
+
| { mount: (ctx: never) => unknown };
|
package/src/island.ts
CHANGED
|
@@ -299,6 +299,40 @@ const assertCloneableProps = (props: Record<string, unknown>, context: string):
|
|
|
299
299
|
);
|
|
300
300
|
};
|
|
301
301
|
|
|
302
|
+
/* ── Style values ─────────────────────────────────────────────────────────
|
|
303
|
+
* Style objects cross the wire verbatim (`style={{ height: 24000000 }}`),
|
|
304
|
+
* so the driver must apply the same unit semantics react-dom does:
|
|
305
|
+
* a nonzero number on a non-unitless property needs 'px' — assigning the
|
|
306
|
+
* bare number to el.style silently no-ops on every real browser.
|
|
307
|
+
* The list is React DOM's isUnitlessNumber set (which is also the CSS spec's
|
|
308
|
+
* unitless property surface). */
|
|
309
|
+
const UNITLESS_CSS = new Set([
|
|
310
|
+
'animationIterationCount', 'aspectRatio', 'borderImageOutset',
|
|
311
|
+
'borderImageSlice', 'borderImageWidth', 'boxFlex', 'boxFlexGroup',
|
|
312
|
+
'boxOrdinalGroup', 'columnCount', 'columns', 'flex', 'flexGrow',
|
|
313
|
+
'flexPositive', 'flexShrink', 'flexNegative', 'flexOrder', 'gridArea',
|
|
314
|
+
'gridRow', 'gridRowEnd', 'gridRowSpan', 'gridRowStart', 'gridColumn',
|
|
315
|
+
'gridColumnEnd', 'gridColumnSpan', 'gridColumnStart', 'fontWeight',
|
|
316
|
+
'lineClamp', 'lineHeight', 'opacity', 'order', 'orphans', 'scale',
|
|
317
|
+
'tabSize', 'widows', 'zIndex', 'zoom', 'fillOpacity', 'floodOpacity',
|
|
318
|
+
'stopOpacity', 'strokeDasharray', 'strokeDashoffset', 'strokeMiterlimit',
|
|
319
|
+
'strokeOpacity', 'strokeWidth',
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
/** Coerce a wire style value to a CSS string — px-suffixes bare numbers
|
|
323
|
+
* on non-unitless keys (custom properties pass verbatim). */
|
|
324
|
+
const cssStyleValue = (key: string, value: unknown): string => {
|
|
325
|
+
if (
|
|
326
|
+
typeof value === 'number' &&
|
|
327
|
+
value !== 0 &&
|
|
328
|
+
!key.startsWith('--') &&
|
|
329
|
+
!UNITLESS_CSS.has(key)
|
|
330
|
+
) {
|
|
331
|
+
return `${value}px`;
|
|
332
|
+
}
|
|
333
|
+
return String(value);
|
|
334
|
+
};
|
|
335
|
+
|
|
302
336
|
export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandle> {
|
|
303
337
|
const {
|
|
304
338
|
el, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
|
|
@@ -436,11 +470,17 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
436
470
|
return;
|
|
437
471
|
}
|
|
438
472
|
if (name === 'style' && typeof value === 'object' && value !== null) {
|
|
439
|
-
const
|
|
473
|
+
const decl = (el as HTMLElement).style;
|
|
474
|
+
const elStyle = decl as unknown as Record<string, string>;
|
|
440
475
|
const prevStyle = (prevProps.get(id)?.style ?? {}) as Record<string, string>;
|
|
441
|
-
const nextStyle = value as Record<string,
|
|
476
|
+
const nextStyle = value as Record<string, unknown>;
|
|
442
477
|
for (const k of Object.keys(prevStyle)) if (!(k in nextStyle)) elStyle[k] = '';
|
|
443
|
-
for (const [k, v] of Object.entries(nextStyle))
|
|
478
|
+
for (const [k, v] of Object.entries(nextStyle)) {
|
|
479
|
+
if (prevStyle[k] !== v) {
|
|
480
|
+
if (k.startsWith('--') || k.includes('-')) decl.setProperty(k, cssStyleValue(k, v));
|
|
481
|
+
else elStyle[k] = cssStyleValue(k, v);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
444
484
|
return;
|
|
445
485
|
}
|
|
446
486
|
if (name === 'className') {
|
|
@@ -555,7 +595,12 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
555
595
|
}
|
|
556
596
|
if (name in node) {
|
|
557
597
|
try {
|
|
558
|
-
|
|
598
|
+
// Presence-attr semantics: the proxy emits attr{name,''} for boolean
|
|
599
|
+
// attributes (checked, disabled…) — assigning '' would read falsy.
|
|
600
|
+
// A boolean live property takes true whenever the attr is present.
|
|
601
|
+
const cur = (node as unknown as Record<string, unknown>)[name];
|
|
602
|
+
(node as unknown as Record<string, unknown>)[name] =
|
|
603
|
+
typeof cur === 'boolean' ? true : value;
|
|
559
604
|
return;
|
|
560
605
|
} catch {
|
|
561
606
|
/* read-only property — use the attribute */
|
|
@@ -576,17 +621,18 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
576
621
|
const prev = prevProps.get(id) ?? {};
|
|
577
622
|
const merged = { ...((prev.style ?? {}) as Record<string, string>) };
|
|
578
623
|
for (const [k, v] of Object.entries(changes)) {
|
|
624
|
+
const cv = cssStyleValue(k, v);
|
|
579
625
|
if (k.startsWith('--') || k.includes('-')) {
|
|
580
626
|
// Custom properties and kebab-case keys live outside the camelCase
|
|
581
627
|
// property surface — el.style['font-size'] is a silent no-op on real
|
|
582
628
|
// DOM; setProperty accepts kebab-case, and '' clears either kind.
|
|
583
|
-
elStyle.setProperty(k,
|
|
584
|
-
} else if (
|
|
629
|
+
elStyle.setProperty(k, cv);
|
|
630
|
+
} else if (cv.endsWith('!important')) {
|
|
585
631
|
// `style.setProperty(k, v, 'important')` crosses as a suffix — the
|
|
586
632
|
// style op has no separate priority channel.
|
|
587
|
-
elStyle.setProperty(k,
|
|
633
|
+
elStyle.setProperty(k, cv.replace(/\s*!important$/, ''), 'important');
|
|
588
634
|
} else {
|
|
589
|
-
elStyleMap[k] =
|
|
635
|
+
elStyleMap[k] = cv;
|
|
590
636
|
}
|
|
591
637
|
merged[k] = v;
|
|
592
638
|
}
|
|
@@ -707,6 +753,10 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
707
753
|
// node maps must survive this op: the following `append` still needs
|
|
708
754
|
// the ids it just created. Stale ids are never reused; the only cost
|
|
709
755
|
// is a few dead map entries on remount.
|
|
756
|
+
// Slot anchors inside get no remove op of their own — unmount them
|
|
757
|
+
// like a `remove` op would so slots callbacks see null and
|
|
758
|
+
// slotNodes doesn't leak.
|
|
759
|
+
unmountSlotSubtree(el);
|
|
710
760
|
el.replaceChildren();
|
|
711
761
|
break;
|
|
712
762
|
}
|
|
@@ -897,7 +947,12 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
897
947
|
void client
|
|
898
948
|
.unmount(instance)
|
|
899
949
|
.catch(() => {})
|
|
900
|
-
.finally(() =>
|
|
950
|
+
.finally(() => {
|
|
951
|
+
// A remount may have registered while teardown was in flight
|
|
952
|
+
// (app/client input swap does destroy → mount back-to-back) —
|
|
953
|
+
// terminate only when the client is still truly idle.
|
|
954
|
+
if ((clientMounts.get(client) ?? 0) === 0) client.terminate();
|
|
955
|
+
});
|
|
901
956
|
}
|
|
902
957
|
},
|
|
903
958
|
};
|