Island apps

An island app is a view tree packaged for the worker: a worker entry registers it with a definer, the shell mounts it by name, and props/events cross the wire in both directions.

Define the worker entry

Two definers, same apps map — poly hosts a registry of named apps (many apps per worker), mono dedicates the worker to one:

dashboard.worker.tsxworker
import { defineViewPolyWorker } from '@preact-mvc/atoll-island/worker';
import { TickerIsland } from './ticker';
import { MetricsIsland } from './metrics';

// One worker entry hosts every island app this file exports a view for.
export const worker = defineViewPolyWorker({
  apps: { ticker: TickerIsland, metrics: MetricsIsland },
});
solo.worker.tsxworker
import { defineViewMonoWorker } from '@preact-mvc/atoll-island/worker';
import { EditorIsland } from './editor';

export const worker = defineViewMonoWorker(EditorIsland);

The shell imports the entry only as a type —import type { worker } from './x.worker' — and spawns it with the inline new Worker(new URL('./x.worker.tsx', import.meta.url), { type: 'module' }) literal so the bundler can detect it.

Everything inside works the same

The worker renderer installs the same vnode interop and render scope, so the framework is unchanged in the worker: @Component/View construction, inject() into the island's own root injector, signals, and lifecycle all behave exactly as on the main thread.

metrics.tsxworker
@Component()
export class MetricsIsland extends View<{ source?: string }> {
  private points = signal<number[]>([]);
  private timer?: ReturnType<typeof setInterval>;

  onInit() {
    // Timers/signals outside an event task still re-render: each write
    // ships an op batch to the shell (an "unsolicited" push).
    this.timer = setInterval(() =>
      this.points.value = [...this.points.value.slice(-59), sample()], 1000);
  }
  onDestroy() { clearInterval(this.timer); }

  render() {
    return <Sparkline points={this.points.value} label={this.props.source} />;
  }
}

viewIsland — stamped app references

viewIsland(name, App) stamps an app so shell code can pass the reference itself instead of a registry string — the reference carries its own name (and optional island-scoped providers via viewIslandApp(App, { providers })):

worker
export const tickerApp = viewIsland('ticker', TickerIsland);

// shell: <Island app={tickerApp} worker={tickerWorker} />
// name comes from the stamp — no string to drift out of sync.

viewIslandApp — the contract attach

For full facade decoupling, the contract module both sides import gets attached to the app at registration: mount/updateProps props are validated against its schema and every declared emit payload is checked worker-side before it goes on the wire:

ticker.contract.tsworker
import { defineIslandContract, z } from '@preact-mvc/atoll-island';

export const tickerContract = defineIslandContract({
  app: 'ticker',
  props: z.object({ source: z.string().optional() }),
  events: { 'worker-ping': z.object({ ticks: z.number() }) },
  worker: () =>
    new Worker(new URL('./ticker.worker.tsx', import.meta.url), {
      type: 'module',
    }),
});
ticker.worker.tsxworker
export const worker = defineViewPolyWorker({
  apps: {
    ticker: viewIslandApp(TickerIsland, { contract: tickerContract }),
  },
});

z is Atoll's bundled schema mini-lib, re-exported so no extra dependency is needed; any safeParse-shaped schema (real zod included) satisfies Schema. The shell then mounts islandComponent(tickerContract) with fully typed props and events — see Shell & mounting.

viewIsland(name, App) stamps an app so shell code can pass the reference itself instead of a registry string — the reference carries its own name (and optional island-scoped providers via viewIslandApp(App, { providers })):

worker
export const tickerApp = viewIsland('ticker', TickerIsland);

// shell: <Island app={tickerApp} worker={tickerWorker} />
// name comes from the stamp — no string to drift out of sync.

Talking to the shell: emit()

worker
// worker side — any event name + structured-cloneable payload:
emit('milestone', { count: this.count.value });

// shell side receives every emit through onEvent:
<Island app="ticker" worker={w} onEvent={(name, payload) => {
  if (name === 'milestone') notify(payload);
}} />

Receiving callbacks: callbackProp

shell
<Island
  app="editor" worker={w}
  props={{ onSave: callbackProp((doc) => save(doc)) }}
/>
worker
// worker side — props.onSave is a real function; calling it posts back
// to the shell and returns a Promise of the shell fn's return value:
async save() {
  const result = await this.props.onSave(this.document.value);
}

Only functions wrapped in callbackProp cross — raw function props fail structured-clone on the wire.