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

ticker.worker.tsxworker
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 } });
dashboard.view.tsxshell
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:

ticker.contract.ts
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',
    }),
});
shell
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)} />
worker
// 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 as callbackProp markers.
  • 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.