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:
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 },
});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 inlinenew 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.
@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 })):
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:
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',
}),
});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 })):
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 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
<Island
app="editor" worker={w}
props={{ onSave: callbackProp((doc) => save(doc)) }}
/>// 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.