Islands
Worker-rendered UI islands: real PreactMVC view trees — View classes, signals, DI, lifecycle — running inside a Web Worker, streamed to the shell DOM over a serialized op protocol. The main thread stays free; the island looks and behaves like an ordinary element.
The shape
shell thread worker thread
┌────────────────────────┐ postMessage ┌─────────────────────────┐
│ <Island app="ticker" │ ◀────────── │ ProxyDocument — the │
│ worker={entry}/> │ ops │ only "DOM" a worker sees│
│ │ │ │ │ │
│ driver.ts — replays │ events ───▶ │ Preact render of the │
│ ops onto real DOM │ │ island's view tree │
└────────────────────────┘ └─────────────────────────┘Two threads, two halves of the package: @preact-mvc/atoll-island is the shell surface, @preact-mvc/atoll-island/worker is the worker entry. The engine underneath is the real Atoll — @preact-mvc/atoll is a thin facade over @atolljs/islands + @atolljs/core (op protocol, proxy DOM, driver) — and the adapter is the piece that knows how to render View classes into it.
Sixty seconds of it working
import { Component, View, signal } from '@preact-mvc/core';
import { defineViewPolyWorker, emit } from '@preact-mvc/atoll-island/worker';
@Component()
export class TickerIsland extends View {
private ticks = signal(0);
onInit() {
setInterval(() => this.ticks.value++, 1000); // out-of-task pushes
}
render() {
return (
<button onClick={() => emit('ping', { ticks: this.ticks.value })}>
ticks: {this.ticks.value}
</button>
);
}
}
export const worker = defineViewPolyWorker({ apps: { ticker: TickerIsland } });import { Island } from '@preact-mvc/atoll-island';
import type { WorkerLike } from '@preact-mvc/atoll-island';
// Literal new URL — Vite must see it to emit the worker bundle.
const tickerWorker = (): WorkerLike =>
new Worker(new URL('../workers/ticker.worker.tsx', import.meta.url), {
type: 'module',
}) as WorkerLike;
// inside a view's render():
<Island
app="ticker"
worker={tickerWorker}
onEvent={(name, payload) => console.log('island says', name, payload)}
/>The button ticks once a second in the worker — each signal write re-renders there and ships an op batch; clicking it emits ping back to the shell's onEvent. The shell never coordinates any of it.
The contract facade
The recommended shape fully decouples the two sides: one shared module describes the island — app name, prop/event schemas, worker factory — and shell and worker each import only that. The call site mounts a facade component with no worker plumbing:
import { defineIslandContract, z } from '@preact-mvc/atoll-island';
export const tickerContract = defineIslandContract({
app: 'ticker',
events: { 'worker-ping': z.object({ ticks: z.number() }) },
worker: () =>
new Worker(new URL('./ticker.worker.tsx', import.meta.url), {
type: 'module',
}),
});import { islandComponent } from '@preact-mvc/atoll-island';
import { tickerContract } from './ticker.contract';
const Ticker = islandComponent(tickerContract);
// No worker prop — the contract carries the factory. onEvent narrows to
// the declared event vocabulary, props to the declared schema.
<Ticker onEvent={(name, payload) => log(name, payload)} />// Attach the same contract worker-side: mount/updateProps props are
// schema-validated and declared emit payloads are checked.
export const worker = defineViewPolyWorker({
apps: { ticker: viewIslandApp(TickerIsland, { contract: tickerContract }) },
});What an island is good for
- Continuous or heavy renderers — tickers, log tailers, live charts: work that would jank the shell runs off-thread entirely.
- Isolation boundaries — an untrusted or crash-prone subtree can't take down the shell; terminating the worker is a clean kill.
- Micro-frontends — a worker entry can host several island apps (
defineViewPolyWorker), one worker serving many independently-propped instances.
Honest constraints
- The wire is
postMessage/structuredClone: props and event payloads must be cloneable; functions cross only ascallbackPropmarkers. - The worker is DOM-free: no
document, no canvas interop, no layout queries — the proxy DOM only knows how to serialize mutations. - The default transport is message-only (
mode: 'poll'). The push mode's SharedArrayBuffer doorbell is faster but needs cross-origin isolation headers (COOP/COEP) — which static hosts like GitHub Pages can't serve.
Shell CSS does apply to island contents: ops replay real elements inside the island container, so ordinary classes (Tailwind utilities included) work in worker markup.
Next: writing island apps, mounting from the shell, or how the protocol works.