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
| Export | Signature | What it does |
|---|---|---|
AtollIsland | h(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. |
useIsland | useIsland(options): { host, handle, status, error } | Headless composable — assign host via :ref on your own element. Scope disposal (unmount, effectScope.stop) destroys the island. |
islandComponent | islandComponent<P>(app?): Component | Proxy component — mounts a worker app the shell never imports; attributes that aren't shell keys become the island props: <ChartsIsland :worker="w" :width="520"/>. |
lazyIsland | lazyIsland(() => import('./worker/apps')): Component | defineAsyncComponent-based lazy variant — a bundle split point; resolves { default: app } or contract modules { app, worker } that carry their own worker factory. |
defineVuePolyWorker | defineVuePolyWorker({ 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. |
defineVueMonoWorker | defineVueMonoWorker(Component) | Instance worker — the 1:1 topology: one script, one app, mounted namelessly. Its bundle carries only that app's dependencies. |
vueIslandApp / emit | vueIslandApp(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.
<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><AtollIsland/></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.
/**
* 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 },
});<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><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-wiredupdatePropscalls. - Fixed-dimension libs (recharts) get width/height as props — there's no ResizeObserver channel into the worker.
emitneeds instance scope — it routes through the active task, so it's free inside event handlers. Async commit hooks (VueonUpdated, Svelte$effect, AngularafterEveryRender) run after the task releases it: capturegetActiveInstance()during setup and re-enter withrunInInstance(scope, () => emit(…))— or, in Angular, just declare anoutput()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/slotsare mount-stable — swap them viakey/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.vueand the worker registry serves.vuecomponents; the same bundler plugin (worker.plugins) compiles both sides.