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 '@solid-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 '@solid-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 compiles JSX for Solid's universal renderer against @solid-mvc/atoll-island/worker, 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 update: each tracked
    // write re-syncs the DOM and 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 '@solid-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.