React — islands
@atolljs/react-island — a worker-hosted React (or imperative proxy-DOM) tree mounted as an ordinary element in a React shell. The worker's render loop produces serialized DOM ops; the main thread just replays them.
Live demo — React in the worker
Four islands, React rendered inside the workers: a counter, a second counter instance, a notes composer, and a 1,000,000-record incident benchmark — the two counters share ONE client (both mounts live in a single worker, counter@N keys, one OS thread), notes gets its own client on the same script, and the incidents benchmark carries a third worker through its lazy contract module. The shell is just a thin <Island/> host — worker emits land in onEvent → React state → the status line.
The incident benchmark is the real-world case for offloading a heavy component. One million incidents exist as lazily generated logical rows — the component is a fixed-height virtualized scroller that renders only the ~22 visible rows plus overscan, no matter where you scroll. Each scroll event crosses the island protocol as a structured payload (the driver stamps scrollTop), React re-computes the window worker-side, and the commit rides back as an op batch — the stats line reports the worker-side re-render time, and a rendered emit updates the shell's status line. The main thread never touches more than a handful of DOM nodes; a million rows of state, generation, and diffing all stay off it.
If the frame is blank, the examples aren't up — run npm run dev:all (dev servers) or npm run serve:all (built apps) from the repository root.
The proxy API
| Export | Signature | What it does |
|---|---|---|
lazyIsland | lazyIsland(loader: () => Promise<{ default: A } | { app, worker? } | A>): FC<IslandAppProps<A> & IslandShellProps> | React.lazy mirrored — the returned component suspends on the dynamic import (a real bundler split point), then mounts the resolved app with props inferred from its signature. Contract modules { app, worker } carry their own worker factory — that's how the incidents island gets a dedicated worker. |
islandComponent | islandComponent<P>('name') | islandComponent(StampedApp) | The pure-contract proxy — the shell never imports the implementation; a registry key + a type-only props import is the whole contract. |
Island | <Island app={ref|name} client|worker props onEvent slots onReady/> | The underlying building block — declarative mountIsland as a component. props dedups by serialized identity. |
islandApp | islandApp('name', app) | Stamps an app (component or {imperative} def) with its registry name — a data property, so references survive minification. |
defineReactPolyWorker | defineReactPolyWorker({ apps }) — react-island/worker | Registry worker — one script serving a whole apps map; islands mount by name and several may share one client/worker. Components wrap through reactIslandApp automatically. |
defineReactMonoWorker | defineReactMonoWorker(app) — react-island/worker | Instance worker — the 1:1 topology: one script, one app, mounted namelessly. Its bundle carries only that app's dependencies. |
Slot / emit | <Slot name> / emit(name, payload) | Transclusion: worker markup hands a real element to the shell's slots map (canvases, Monaco, AG Grid). emit is the island→shell event channel. |
Mounting — the proxies make islands look local
The counters mount through plain <Island/> elements on a shared connectIslandWorker client; notes mounts through islandComponent('notes') — a registry key is the whole contract; incidents mounts through lazyIsland with a contract module, so its chunk code-splits and its worker is self-contained. <Suspense> covers the module load; the proxy's fallback prop covers the worker-mount window — mounting can't suspend because a suspended tree never commits and the container must be in the DOM first.
/**
* The React islands demo — REACT RUNNING INSIDE WORKERS.
*
* Each island below mounts an app from `worker/react.worker.tsx`'s
* registry: ordinary React components (useState/useLayoutEffect) rendered
* by react-reconciler into serialized DOM ops. The worker bundle carries
* React; this page is just a thin host that replays its ops.
*
* What changes versus main.ts (the seven-island demo):
* - The shell itself is React — each island is an <Island/> element, a
* islandComponent facade, or a lazyIsland contract module.
* - `client` (not `worker:`) is passed so the doorbell choice travels
* with the connection; the two counters deliberately share ONE client
* so their islands live in the same OS worker.
* - Mediation is plain React state: worker emits land in onEvent →
* setState → the status line.
*
* The seven-island demo lives in index.html — this page is the small
* framework-island edition matching vue/solid/svelte/angular-shell.html.
*/
import { useRef, useState, Suspense } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { Island, islandComponent, lazyIsland } from '@atolljs/react-island';
import { connectIslandWorker } from '@atolljs/islands';
import type { IslandHandle, Mode } from '@atolljs/islands';
/** The registry worker — one script serving all three React apps. */
const reactWorker = (): Worker =>
new Worker(new URL('./worker/react.worker.tsx', import.meta.url), { type: 'module' });
// SharedArrayBuffer only exists in cross-origin-isolated contexts — on
// hosts without COOP/COEP (GitHub Pages where coi-sw.js didn't take, or a
// browser without `credentialless`) the doorbell can't bind, so every
// island runs its 50ms poll transport instead of push.
const isolated =
typeof SharedArrayBuffer !== 'undefined' &&
(typeof window.crossOriginIsolated === 'undefined' || window.crossOriginIsolated);
const initialMode: Mode = isolated ? 'push' : 'poll';
/**
* Two clients = two OS workers running the same script. The counters share
* one client (one worker, two 'counter@N' instances); notes gets its own —
* and the incidents benchmark carries a third worker through its contract
* module below.
*/
const counterClient = connectIslandWorker({ worker: reactWorker, doorbell: isolated });
const notesClient = connectIslandWorker({ worker: reactWorker, doorbell: isolated });
/**
* The facades — mount worker apps this shell never imports. Notes goes
* through an eager proxy component (the string is the registry key);
* incidents is lazy: a dynamic import + contract module that carries its
* OWN worker, so the 1M-row benchmark can't contend with anything else
* and its chunk only loads when the island mounts.
*/
const NotesIsland = islandComponent<{ title?: string }>('notes');
const IncidentsIsland = lazyIsland(() => import('./incidents.island'));
/* ── Shell ──────────────────────────────────────────────────────────────── */
const loading = (
<div className="island-root" style={{ color: '#7d8a9c' }}>
loading worker module…
</div>
);
function IslandPanel(props: {
title: string;
badge?: string;
children: ReactNode;
}): ReactElement {
return (
<section className="island">
<div className="island-head">
<span>{props.title}</span>
<span className="badge">{props.badge ?? 'worker …'}</span>
</div>
{props.children}
</section>
);
}
export function Shell(): ReactElement {
// Mediation state — worker emits land here; the status line renders it.
const [status, setStatus] = useState('mounting islands…');
const [mode, setModeState] = useState<Mode>(initialMode);
const [pids, setPids] = useState<Record<string, string>>({});
const [statsTick, setStatsTick] = useState(0);
/** Every mounted island's handle — setMode + the aggregate stats read them. */
const handles = useRef(new Map<string, IslandHandle>());
const modeRef = useRef(mode);
modeRef.current = mode;
const ready = (key: string) => (h: IslandHandle): void => {
handles.current.set(key, h);
h.setMode(modeRef.current); // applies the user's pick to late-mounting islands
setPids((p) => ({ ...p, [key]: `worker ${h.pid}` }));
setStatsTick((t) => t + 1);
};
const bump = (): void => setStatsTick((t) => t + 1);
const setMode = (next: Mode): void => {
setModeState(next);
for (const h of handles.current.values()) h.setMode(next);
bump();
};
const flushes = [...handles.current.values()].reduce((a, i) => a + i.flushCalls, 0);
const ops = [...handles.current.values()].reduce((a, i) => a + i.opsApplied, 0);
void statsTick; // re-render trigger for the aggregate read
const counterOpts = (label: string, key: string) => ({
client: counterClient,
app: 'counter',
props: { label },
mode: initialMode,
onReady: ready(key),
onActivity: bump,
onEvent: (name: string, payload: unknown) => {
const p = payload as { count?: number; label?: string };
if (name === 'incremented')
setStatus(`${p.label} counter → ${p.count} (React state stayed in the worker)`);
},
});
return (
<>
<h1>React islands — React in the worker</h1>
<p style={{ font: '12px monospace', color: '#9aa4b2', marginTop: -8 }}>
registry worker + shared client — the worker bundle carries React, the shell is a thin{' '}
<code>{'<Island/>'}</code> + <code>islandComponent</code>/<code>lazyIsland</code> host.{' '}
<a href="./index.html" style={{ color: '#7fb6ff' }}>framework-free shell →</a>
</p>
<div id="transport-bar">
<span>transport:</span>
<label
id="transport-toggle"
title={isolated ? 'push via SharedArrayBuffer doorbell — off falls back to 50ms polling' : 'needs cross-origin isolation (no SharedArrayBuffer)'}
>
<input
id="push-toggle"
type="checkbox"
checked={mode === 'push'}
disabled={!isolated}
onChange={() => setMode(mode === 'push' ? 'poll' : 'push')}
/>
push (SAB doorbell)
</label>
<span id="transport-stats">
sync: {mode} · flush calls: {flushes} · ops applied: {ops}
</span>
</div>
<div id="status-line">{status}</div>
<IslandPanel title="app: counter (react-reconciler — this worker has no DOM)" badge={pids.counter}>
<Island {...counterOpts('alpha', 'counter')} className="island-root" />
</IslandPanel>
<IslandPanel
title="app: counter — second instance (SAME worker as the first, one client)"
badge={pids.counter2}
>
<Island {...counterOpts('beta', 'counter2')} className="island-root" />
</IslandPanel>
<IslandPanel
title="app: notes — islandComponent facade (attrs ARE the props)"
badge={pids.notes}
>
<NotesIsland
client={notesClient}
title="react island"
onReady={ready('notes')}
onActivity={bump}
onEvent={(name, payload) => {
const p = payload as { text?: string; total?: number };
if (name === 'noteAdded')
setStatus(`notes island emitted noteAdded → "${p.text}" (${p.total} total)`);
}}
containerProps={{ className: 'island-root' }}
/>
</IslandPanel>
<IslandPanel
title="app: incidents — lazyIsland + contract module (own worker, 1M rows)"
badge={pids.incidents}
>
<Suspense fallback={loading}>
<IncidentsIsland
// The contract supplies the worker; workerOptions carries the
// doorbell choice through to the shorthand client it builds.
workerOptions={{ doorbell: isolated }}
mode={initialMode}
onReady={ready('incidents')}
onActivity={bump}
onEvent={(name, payload) => {
const p = payload as { start?: number; end?: number; ms?: number };
if (name === 'rendered')
setStatus(
`incidents island rendered rows ${p.start?.toLocaleString()}–${p.end?.toLocaleString()} in ${p.ms?.toFixed(1)}ms (of 1,000,000)`,
);
}}
containerProps={{ className: 'island-root' }}
/>
</Suspense>
</IslandPanel>
</>
);
}
// Guarded so the module is import-safe in tests (they render <Shell/> directly).
const rootEl = document.getElementById('root');
if (rootEl) createRoot(rootEl).render(<Shell />);The worker side — React running in the worker
The worker bundle carries React itself — defineReactPolyWorker serves the whole apps map from one script, and a real react-reconciler drives the proxy DOM with state staying worker-side. Components are unremarkable React: hooks, controlled inputs, emit(name, payload) for the island → shell channel. The only rules: no DOM access, serializable props, and handlers receive the plain EventPayload wire object instead of a SyntheticEvent — handler() in the file adapts it to JSX's event prop types. See the Islands page for modes, slots, and lifecycle.
/**
* React island worker — a registry worker serving React apps through
* `reactIslandApp` (react-reconciler bound to the instance's op stream).
* Its bundle carries React but no Vue/Solid/Svelte/Angular — the point of
* the demo: islands are framework-agnostic over one op protocol, and this
* worker's apps are the same counter/notes/incidents set the other
* framework shells mount.
*
* Unlike the other frameworks' async schedulers, React's commit runs inside
* the dispatch's sync lane — `useLayoutEffect` fires while the task still
* holds the instance, so `emit` from a commit-phase effect needs no
* `runInInstance` re-entry (see TableApp's `rowsChanged` in apps.tsx).
*/
import { useLayoutEffect, useRef, useState } from 'react';
import { defineReactPolyWorker, emit } from '@atolljs/react-island/worker';
import { islandApp, type EventPayload } from '@atolljs/islands/worker';
/**
* Worker-side event handlers receive the plain wire payload — { type, value,
* checked, key, scrollTop } — not a SyntheticEvent. `handler()` adapts them
* to the DOM event prop types JSX expects; the cast is the whole story.
*/
const handler = <E,>(fn: (e: EventPayload) => void): ((e: E) => void) =>
fn as unknown as (e: E) => void;
/**
* 'counter' — the canonical framework island: a `label` wire prop, a
* `useState` count, and an 'incremented' emit for the shell's status line.
*/
const CounterApp = islandApp('counter', function CounterApp({
label = 'count',
}: {
label?: string;
}) {
const [count, setCount] = useState(0);
return (
<div className="react-counter">
<span className="vanilla-heading">
{label}: {count}
</span>
<button
className="mw-btn"
onClick={handler(() => {
const n = count + 1;
setCount(n);
emit('incremented', { count: n, label });
})}
>
increment
</button>
</div>
);
});
/**
* 'notes' — the notes composer: a controlled input, a `useState` list, and
* a 'noteAdded' emit per add. `value` is a wire prop — the driver writes it
* back onto the real input, so clearing the draft clears the field.
*/
const NotesApp = islandApp('notes', function NotesApp({
title = 'react island',
}: {
title?: string;
}) {
const [draft, setDraft] = useState('');
const [notes, setNotes] = useState<string[]>([]);
const add = (): void => {
const text = draft.trim();
if (text === '') return;
setNotes([...notes, text]);
setDraft('');
emit('noteAdded', { text, total: notes.length + 1 });
};
return (
<div className="react-notes">
<h3 className="vanilla-heading">{title}</h3>
<div className="atoll-map-places">
<input
placeholder="write a note…"
value={draft}
// _enrichEvent stamps the wire payload's `value` onto the target.
onInput={handler((e) => setDraft(e.value ?? ''))}
onKeyDown={handler((e) => {
if (e.key === 'Enter') add();
})}
/>
<button className="atoll-map-place-btn" onClick={handler(add)}>
add
</button>
</div>
<ul className="vanilla-log">
{notes.map((n, i) => (
<li key={i} className="vanilla-log-line">
{n}
</li>
))}
</ul>
<div className="vanilla-readout">{notes.length} note(s) — state lives in the worker</div>
</div>
);
});
/**
* 'incidents' — the heavy-component benchmark: 1,000,000 incident records
* in the worker, rendered through a virtualized scroller. The main thread
* only ever sees ~22 rows of ops no matter how deep the user scrolls.
*
* Rows are lazily generated (deterministic pseudo-data — nothing is
* materialized until it's visible). Each scroll event is a dispatch
* round-trip — the driver stamps `scrollTop` onto the payload — the worker
* re-renders the window and reports the re-render time back via 'rendered'.
*/
const REGIONS = ['us-east', 'us-west', 'eu-central', 'ap-south', 'sa-east'];
const SEVS = ['P1', 'P2', 'P3', 'P4'];
const ROW_H = 24;
const OV = 4;
const VISIBLE = Math.ceil(320 / ROW_H) + OV * 2;
const incident = (i: number) => ({
id: i,
site: `site-${(i * 7919) % 1409}`,
region: REGIONS[i % REGIONS.length],
sev: (i * 31) % 100,
dur: `${((i * 104729) % 977) % 60}m`,
});
const sevClass = (s: number): string =>
`inc-sev sev-p${s > 75 ? 1 : s > 40 ? 2 : s > 15 ? 3 : 4}`;
const sevLabel = (s: number): string => SEVS[s > 75 ? 0 : s > 40 ? 1 : s > 15 ? 2 : 3];
const IncidentsApp = islandApp('incidents', function IncidentsApp({
count = 1_000_000,
}: {
count?: number;
}) {
const [start, setStart] = useState(0);
const [lastMs, setLastMs] = useState(0);
const t0 = useRef(performance.now());
const prevKey = useRef('');
const first = Math.min(start, Math.max(0, count - VISIBLE));
const rows = Array.from({ length: Math.min(VISIBLE, count - first) }, (_, k) =>
incident(first + k),
);
const end = first + rows.length - 1;
// Commit-phase report: the delta from the scroll handler is the whole
// worker-side re-render cost. React commits inside the dispatch's sync
// lane, so emit here is still in instance scope — no runInInstance
// re-entry needed. The window-key guard stops lastMs's own write from
// looping back into another report.
useLayoutEffect(() => {
const key = `${first}:${end}`;
if (key === prevKey.current) return;
prevKey.current = key;
const ms = performance.now() - t0.current;
setLastMs(ms);
emit('rendered', { start: first, end, ms });
});
return (
<div className="incidents">
<div className="inc-stats">
{count.toLocaleString()} incidents · rows {first.toLocaleString()}–
{end.toLocaleString()} · worker re-render {lastMs.toFixed(1)}ms
</div>
<div
className="inc-viewport"
onScroll={handler((e) => {
t0.current = performance.now();
// The driver stamps the scroller's scrollTop onto the wire
// payload — the proxy element's geometry getters are stubs.
setStart(Math.max(0, Math.floor((e.scrollTop ?? 0) / ROW_H) - OV));
})}
>
<div className="inc-spacer" style={{ height: count * ROW_H }}>
{rows.map((r) => (
<div key={r.id} className="inc-row" style={{ top: r.id * ROW_H }}>
<span className="inc-id">#{r.id}</span>
<span className="inc-site">{r.site}</span>
<span className="inc-region">{r.region}</span>
<span className={sevClass(r.sev)}>
{sevLabel(r.sev)} · {r.sev}
</span>
<span className="inc-dur">{r.dur}</span>
</div>
))}
</div>
</div>
</div>
);
});
export const reactWorker = defineReactPolyWorker({
apps: { counter: CounterApp, notes: NotesApp, incidents: IncidentsApp },
});The incidents island's contract module is the whole lazy story: a shell-safe module that names the registry key AND carries the worker factory — the shell-side lazyIsland resolves both, so the benchmark's worker is a bundler-detectable split point.
/**
* Island contract for the `lazyIsland` facade — resolves `{ app, worker }`
* so the island carries its own worker factory and the call site needs no
* `worker` prop at all. The dynamic `import('./incidents.island')` in
* shell.tsx is the bundler's split point: this module (and anything it
* pulls) only loads when the island mounts.
*
* `app` is the registry key as a STRING — importing the component itself
* would drag worker-side code into the shell bundle; the name is the
* whole contract.
*/
export const app = 'incidents';
export const worker = (): Worker =>
new Worker(new URL('./worker/react.worker.tsx', import.meta.url), { type: 'module' });Notes
- Mediation is unidirectional — an island's
emitlands inonEvent, the shell sets state, and it flows back in as props. No hand-wiredupdatePropscalls. - Two fallback phases:
<Suspense>for the module load, the proxy'sfallbackprop for the worker mount. - React commits in the dispatch's sync lane — unlike the other frameworks' async schedulers,
useLayoutEffectfires while the task still holds the instance, so commit-phaseemitneeds norunInInstancere-entry. That's how the incidents benchmark reports its re-render time from a layout effect. - Fixed-dimension libs (recharts) get width/height as props — there's no ResizeObserver channel into the worker; measurement reads on the proxy DOM return zero.
- Structural walls stay walls: closed libraries that need real DOM (Google Maps JS) are housed via transclusion slots or iframe elements — the worker owns the box, the shell owns the contents.