Vue — islands

@atolljs/vue-island — a worker-hosted tree mounted as an ordinary element in a Vue shell. The worker's render loop produces serialized DOM ops; the main thread just replays them.

Live demo — Vue in the worker

Four islands, Vue rendered inside the workers: a counter, a second counter instance, a notes composer, and a 1,000,000-record incident benchmark — the two counters share ONE client — both mounts live in a single worker (counter@N keys, one OS thread); notes and the incidents benchmark each get their own worker on the same script. The shell is just a thin <AtollIsland/> host from @atolljs/vue-island that replays their ops.

The incident benchmark is the real-world case for offloading a heavy component. One million incidents exist as lazily generated logical rows — the component is a fixed-height virtualized scroller that renders only the ~22 visible rows plus overscan, no matter where you scroll. Each scroll event crosses the island protocol as a structured payload (the driver stamps scrollTop), Vuere-computes the window worker-side, and the commit rides back as an op batch — the stats line reports the worker-side re-render time, and a rendered emit updates the shell's status line. The main thread never touches more than a handful of DOM nodes; a million rows of state, generation, and diffing all stay off it.

If the frame is blank, the examples aren't up — run npm run dev:all (dev servers) or npm run serve:all (built apps) from the repository root.

The Vue API

ExportSignatureWhat it does
AtollIslandh(AtollIsland, { client|worker, app, props, slots, onEvent, onActivity, onReady, onError })Component form — renders the host div itself (class/id attrs fall through onto it). props may be a reactive() object or getter; the on* callbacks are read at call time, so fresh closures never remount.
useIslanduseIsland(options): { host, handle, status, error }Headless composable — assign host via :ref on your own element. Scope disposal (unmount, effectScope.stop) destroys the island.
islandComponentislandComponent<P>(app?): ComponentProxy component — mounts a worker app the shell never imports; attributes that aren't shell keys become the island props: <ChartsIsland :worker="w" :width="520"/>.
lazyIslandlazyIsland(() => import('./worker/apps')): ComponentdefineAsyncComponent-based lazy variant — a bundle split point; resolves { default: app } or contract modules { app, worker } that carry their own worker factory.
defineVuePolyWorkerdefineVuePolyWorker({ apps })Registry worker — one script serving a whole apps map; islands mount by name and several may share one client. Plain components wrap through vueIslandApp automatically.
defineVueMonoWorkerdefineVueMonoWorker(Component)Instance worker — the 1:1 topology: one script, one app, mounted namelessly. Its bundle carries only that app's dependencies.
vueIslandApp / emitvueIslandApp(Component) / emit(name, payload)The adapter for shared framework-agnostic registries, and the island → shell event channel (re-exported from the /worker entry).

Mounting — the shell is a thin Vue host

Every island below mounts against a pre-connected connectIslandWorker({ worker, doorbell }) client. Worker emits land in onEvent → shell state → the status line; props flow the other way — passes a plain or reactive() object — the binding watches it and pushes updateProps, deduped by serialized identity.

examples/react-dom-worker/src/vue/Shell.vuefrontend
<script setup lang="ts">
/**
 * The Vue islands demo — VUE RUNNING INSIDE WORKERS.
 *
 * Each island below mounts an app from `worker/vue.worker.ts`'s registry:
 * real `.vue` SFCs (script-setup + template) rendered by Vue's
 * createRenderer against the proxy DOM. The worker bundle carries Vue;
 * this page is just a thin host that replays its ops.
 *
 *   - `client` (not `worker:`) is passed so the doorbell choice travels
 *     with the connection; the two counters deliberately share ONE client
 *     so their islands live in the same OS worker ('counter@0' /
 *     'counter@1').
 *   - Mediation is reactive state: worker emits mutate `ref`s/`reactive()`
 *     that feed the status line.
 *   - Mount styles escalate: `<AtollIsland>` (counters), `islandComponent`
 *     (notes — plain attrs forward as the island's props), `lazyIsland` +
 *     a contract module (incidents — the island carries its own worker).
 *
 * The seven-island React demo lives in index.html / react-shell.html —
 * this page is the small framework-island edition from the consumer docs.
 */
import { reactive, ref } from 'vue';
import { AtollIsland, islandComponent, lazyIsland } from '@atolljs/vue-island';
import { connectIslandWorker } from '@atolljs/islands';
import type { IslandHandle, Mode } from '@atolljs/islands';

/** The registry worker — one script serving all Vue apps. */
const vueWorker = (): Worker =>
  new Worker(new URL('../worker/vue.worker.ts', import.meta.url), { type: 'module' });

// SharedArrayBuffer only exists in cross-origin-isolated contexts — on
// hosts without COOP/COEP (GitHub Pages where coi-sw.js didn't take, or a
// browser without `credentialless`) the doorbell can't bind, so every
// island runs its 50ms poll transport instead of push.
const isolated =
  typeof SharedArrayBuffer !== 'undefined' &&
  (typeof window.crossOriginIsolated === 'undefined' || window.crossOriginIsolated);
const initialMode: Mode = isolated ? 'push' : 'poll';

/**
 * Two clients = two OS workers running the same script. The counters share
 * one client (one worker, two 'counter@N' instances); notes gets its own —
 * and the incidents benchmark carries a third worker through its contract
 * module below.
 */
const counterClient = connectIslandWorker({ worker: vueWorker, doorbell: isolated });
const notesClient = connectIslandWorker({ worker: vueWorker, doorbell: isolated });

/**
 * The facades — mount worker apps this shell never imports. Notes goes
 * through an eager proxy component (the string is the registry key);
 * incidents is lazy: a dynamic import + contract module that carries its
 * OWN worker, so the 1M-row benchmark can't contend with anything else
 * and its chunk only loads when the island mounts.
 */
const NotesIsland = islandComponent<{ title?: string }>('vue-notes');
const IncidentsIsland = lazyIsland(() => import('./incidents.island'));

const state = reactive({
  status: 'mounting islands…',
  mode: initialMode as Mode,
  pids: {} as Record<string, string>,
});
/** Every mounted island's handle — setMode + the aggregate stats read them. */
const handles = new Map<string, IslandHandle>();
/** Bump counter — re-render trigger for the aggregate stats read. */
const statsTick = ref(0);

const ready =
  (key: string) =>
  (handle: IslandHandle): void => {
    handles.set(key, handle);
    handle.setMode(state.mode); // applies the user's pick to late-mounting islands
    state.pids[key] = `worker ${handle.pid}`;
    statsTick.value++;
  };
const bump = (): void => {
  statsTick.value++;
};
const setMode = (mode: Mode): void => {
  state.mode = mode;
  for (const handle of handles.values()) handle.setMode(mode);
  bump();
};
const statsText = (): string => {
  void statsTick.value; // tracked read — stats text re-renders per op batch
  const flushes = [...handles.values()].reduce((a, i) => a + i.flushCalls, 0);
  const ops = [...handles.values()].reduce((a, i) => a + i.opsApplied, 0);
  return `sync: ${state.mode} · flush calls: ${flushes} · ops applied: ${ops}`;
};

const counterOpts = (label: string, key: string) => ({
  client: counterClient,
  app: 'counter',
  props: { label },
  onReady: ready(key),
  onActivity: bump,
  onEvent: (name: string, payload: unknown) => {
    const p = payload as { count?: number; label?: string };
    if (name === 'incremented')
      state.status = `${p.label} counter → ${p.count} (Vue state stayed in the worker)`;
  },
});

// Facade opts — every attribute that isn't a shell key forwards as the
// island's props, so `title` here reaches the worker app directly (no
// `props:` wrapper).
const notesOpts = {
  client: notesClient,
  title: 'vue island',
  onReady: ready('notes'),
  onActivity: bump,
  onEvent: (name: string, payload: unknown) => {
    const p = payload as { text?: string; total?: number };
    if (name === 'noteAdded')
      state.status = `notes island emitted noteAdded → "${p.text}" (${p.total} total)`;
  },
};

// The contract module supplies the worker — the shell only passes the
// doorbell choice (inside workerOptions) and the callbacks.
const incidentsOpts = {
  workerOptions: { doorbell: isolated },
  onReady: ready('incidents'),
  onActivity: bump,
  onEvent: (name: string, payload: unknown) => {
    const p = payload as { start?: number; end?: number; ms?: number };
    if (name === 'rendered')
      state.status = `incidents island rendered rows ${p.start?.toLocaleString()}–${p.end?.toLocaleString()} in ${p.ms?.toFixed(1)}ms (of 1,000,000)`;
  },
};
</script>

<template>
  <h1>Vue islands — Vue in the worker</h1>
  <p style="font: 12px monospace; color: #9aa4b2; margin-top: -8px">
    registry worker + shared client — the worker bundle carries Vue, the shell is a thin
    <code>&lt;AtollIsland/&gt;</code> + <code>islandComponent</code>/<code>lazyIsland</code> facade host.
    <a href="./index.html" style="color: #7fb6ff">framework-free shell →</a>
  </p>

  <div id="transport-bar">
    <span>transport:</span>
    <label
      id="transport-toggle"
      :title="isolated ? 'push via SharedArrayBuffer doorbell — off falls back to 50ms polling' : 'needs cross-origin isolation (no SharedArrayBuffer)'"
    >
      <input
        id="push-toggle"
        type="checkbox"
        :checked="state.mode === 'push'"
        :disabled="!isolated"
        @change="setMode(state.mode === 'push' ? 'poll' : 'push')"
      />
      push (SAB doorbell)
    </label>
    <span id="transport-stats">{{ statsText() }}</span>
  </div>
  <div id="status-line">{{ state.status }}</div>

  <section class="island">
    <div class="island-head">
      <span>app: counter (Vue createRenderer)</span>
      <span class="badge">{{ state.pids.counter ?? 'worker …' }}</span>
    </div>
    <AtollIsland v-bind="counterOpts('alpha', 'counter')" class="island-root" />
  </section>

  <section class="island">
    <div class="island-head">
      <span>app: counter — second instance (SAME worker as the first, one client)</span>
      <span class="badge">{{ state.pids.counter2 ?? 'worker …' }}</span>
    </div>
    <AtollIsland v-bind="counterOpts('beta', 'counter2')" class="island-root" />
  </section>

  <section class="island">
    <div class="island-head">
      <span>app: notes — islandComponent facade (attrs ARE the props)</span>
      <span class="badge">{{ state.pids.notes ?? 'worker …' }}</span>
    </div>
    <NotesIsland v-bind="notesOpts" class="island-root" />
  </section>

  <section class="island">
    <div class="island-head">
      <span>app: incidents — lazyIsland + contract module (own worker, 1M rows)</span>
      <span class="badge">{{ state.pids.incidents ?? 'worker …' }}</span>
    </div>
    <IncidentsIsland v-bind="incidentsOpts" class="island-root" />
  </section>
</template>

The worker side — Vue running in the worker

The worker bundle carries Vue itself — the framework's own renderer drives the proxy DOM and state stays worker-side.defineVuePolyWorker serves the whole apps map from one script; islands mount by registry name. See the Islands page for modes, slots, and lifecycle.

examples/react-dom-worker/src/worker/vue.worker.tsfrontend
/**
 * Vue island worker — a registry worker serving Vue SFC apps through
 * `vueIslandApp` (Vue's createRenderer bound to the instance's proxy DOM).
 * Its bundle carries Vue but no React — the point of the demo: islands are
 * framework-agnostic over one op protocol.
 *
 * Components are real `.vue` single-file components — vite compiles them
 * for the worker bundle exactly like a client bundle (plugin-vue runs in
 * the worker build via `worker.plugins` in vite.config.ts).
 */
import { defineVuePolyWorker } from '@atolljs/vue-island/worker';
import Counter from './vue/Counter.vue';
import Incidents from './vue/Incidents.vue';
import Notes from './vue/Notes.vue';

export const vueWorker = defineVuePolyWorker({
  apps: { 'vue-notes': Notes, counter: Counter, incidents: Incidents },
});
examples/react-dom-worker/src/worker/vue/Counter.vuefrontend
<script setup lang="ts">
/**
 * 'counter' — the docs' canonical Vue island as an SFC: a `label` wire
 * prop, a local `ref` count, a delegated click that re-renders, and an
 * 'incremented' emit for the shell's status line. Vue's createRenderer is
 * bound to the instance's proxy DOM — this component runs entirely in the
 * worker.
 */
import { ref } from 'vue';
import { emit } from '@atolljs/vue-island/worker';

const props = withDefaults(defineProps<{ label?: string }>(), { label: 'count' });
const count = ref(0);
const bump = (): void => {
  count.value += 1;
  emit('incremented', { count: count.value, label: props.label });
};
</script>

<template>
  <div class="vue-counter">
    <span class="vanilla-heading">{{ label }}: {{ count }}</span>
    <button class="mw-btn" @click="bump">increment</button>
  </div>
</template>
examples/react-dom-worker/src/worker/vue/Incidents.vuefrontend
<script setup lang="ts">
/**
 * 'incidents' — the heavy-component benchmark: 1,000,000 incident records
 * in the worker, rendered through a virtualized scroller. The main thread
 * only ever sees ~20 rows of ops no matter how deep the user scrolls.
 *
 * Rows are lazily generated (deterministic pseudo-data — nothing is
 * materialized until it's visible). Each scroll event is a dispatch
 * round-trip; the worker re-renders the window and reports the re-render
 * time back via 'rendered' — that's the number that matters.
 */
import { computed, onUpdated, ref } from 'vue';
import { emit, runInInstance } from '@atolljs/vue-island/worker';
import { getActiveInstance } from '@atolljs/islands/worker';

const props = withDefaults(defineProps<{ count?: number }>(), { count: 1_000_000 });
const ROW_H = 24;
const VIEW = 320;
const OV = 4;
const VISIBLE = Math.ceil(VIEW / ROW_H) + OV * 2;

const REGIONS = ['us-east', 'us-west', 'eu-central', 'ap-south', 'sa-east'];
const SEVS = ['P1', 'P2', 'P3', 'P4'];
const incident = (i: number) => ({
  id: i,
  site: `site-${(i * 7919) % 1409}`,
  region: REGIONS[i % REGIONS.length],
  sev: (i * 31) % 100,
  dur: `${((i * 104729) % 977) % 60}m`,
});
const sevClass = (s: number): string =>
  `inc-sev sev-p${s > 75 ? 1 : s > 40 ? 2 : s > 15 ? 3 : 4}`;
const sevLabel = (s: number): string => SEVS[s > 75 ? 0 : s > 40 ? 1 : s > 15 ? 2 : 3];

const start = ref(0);
const lastMs = ref(0);
let t0 = performance.now();

const first = computed(() =>
  Math.min(start.value, Math.max(0, props.count - VISIBLE)),
);
const rows = computed(() =>
  Array.from({ length: Math.min(VISIBLE, props.count - first.value) }, (_, k) =>
    incident(first.value + k),
  ),
);
const end = computed(() => first.value + rows.value.length - 1);

const onScroll = (e: Event): void => {
  t0 = performance.now();
  // The driver stamps the scroller's scrollTop onto the wire payload —
  // e.target is the proxy element, whose geometry getters are stubs.
  const st = (e as Event & { scrollTop?: number }).scrollTop ?? 0;
  start.value = Math.max(0, Math.floor(st / ROW_H) - OV);
};
// onUpdated runs after the re-render commits — the delta from the scroll
// handler is the whole worker-side re-render cost. The window-key guard
// stops lastMs's own write from looping back into another report. Vue's
// scheduler flushes async, so the callback runs with no instance scope —
// capture this mount's key during setup (which runs inside the mount task)
// and re-enter it for the emit.
const scope = getActiveInstance();
let prevKey = '';
onUpdated(() => {
  const key = `${first.value}:${end.value}`;
  if (key === prevKey) return;
  prevKey = key;
  lastMs.value = performance.now() - t0;
  runInInstance(scope, () =>
    emit('rendered', { start: first.value, end: end.value, ms: lastMs.value }),
  );
});
</script>

<template>
  <div class="inc-stats">
    {{ count.toLocaleString() }} incidents · rows {{ first.toLocaleString() }}–{{ end.toLocaleString() }} ·
    worker re-render {{ lastMs.toFixed(1) }}ms
  </div>
  <div class="inc-viewport" @scroll="onScroll">
    <div class="inc-spacer" :style="{ height: `${count * ROW_H}px` }">
      <div
        v-for="r in rows"
        :key="r.id"
        class="inc-row"
        :style="{ top: `${r.id * ROW_H}px` }"
      >
        <span class="inc-id">#{{ r.id }}</span>
        <span class="inc-site">{{ r.site }}</span>
        <span class="inc-region">{{ r.region }}</span>
        <span :class="sevClass(r.sev)">{{ sevLabel(r.sev) }} · {{ r.sev }}</span>
        <span class="inc-dur">{{ r.dur }}</span>
      </div>
    </div>
  </div>
</template>

Notes

  • Mediation is unidirectional — an island's emit lands in onEvent, the shell writes state, and it flows back in as props. No hand-wired updateProps calls.
  • Fixed-dimension libs (recharts) get width/height as props — there's no ResizeObserver channel into the worker.
  • emit needs instance scope — it routes through the active task, so it's free inside event handlers. Async commit hooks (Vue onUpdated, Svelte $effect, Angular afterEveryRender) run after the task releases it: capture getActiveInstance() during setup and re-enter with runInInstance(scope, () => emit(…)) — or, in Angular, just declare an output() field and the adapter's output bridging re-enters for you (that's how the incidents benchmark reports its re-render time).
  • Mounting is async — the host div commits immediately and the worker's first op batch fills it; no Suspense-style fallback plumbing is needed.
  • client/worker/app/slots are mount-stable — swap them via key/v-if, not mid-life.
  • Vue's scheduler is a microtask — state-driven re-renders commit just after the dispatch task returns; their ops ride the doorbell/flush path.
  • The demo is real SFCs end to end — the shell is Shell.vue and the worker registry serves .vue components; the same bundler plugin (worker.plugins) compiles both sides.