Svelte — islands

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

Live demo — Svelte in the worker

Four islands, Svelte rendered inside the workers: a counter, a second counter instance, a notes composer, and a 1,000,000-record incident benchmark — each island gets its own client — Svelte schedules render work through ambient document resolution, so mounts on one shared worker can misroute ops. The shell is just a thin use:island host from @atolljs/svelte-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), Sveltere-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 Svelte API

ExportSignatureWhat it does
island<div use:island={{ client|worker, app, props, onEvent, slots, onReady }} />Action form — mounts on attach, forwards props on update(), destroys on teardown. Callbacks are read through a latest-options ref, so fresh closures never remount.
createIslandStatecreateIslandState(): { status, error, handle }Headless rune state — spread onto the action options to read status/error/handle reactively.
defineSveltePolyWorkerdefineSveltePolyWorker({ apps })Registry worker — components must be rune-compiled Svelte 5 (vite-plugin-svelte); they mount through real mount()/unmount() against the proxy document.
defineSvelteMonoWorkerdefineSvelteMonoWorker(Component)Instance worker — the 1:1 topology, mounted namelessly.
svelteIslandApp / emitsvelteIslandApp(Component) / emit(name, payload)The adapter for shared registries, and the island → shell event channel.

Mounting — the shell is a thin Svelte 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 — the action's update() hook fires when the options object re-evaluates — props changes push updateProps, deduped by serialized identity.

examples/react-dom-worker/src/svelte/Shell.sveltefrontend
<script lang="ts">
  /**
   * The Svelte islands demo — SVELTE RUNNING INSIDE WORKERS.
   *
   * Each island below mounts an app from `worker/svelte.worker.ts`'s
   * registry: real `.svelte` components (runes + {#each}) rendered by
   * Svelte's `mount()` against the proxy DOM. The worker bundle carries
   * Svelte; this page is just a thin host that replays its ops.
   *
   * What changes versus shell.tsx:
   *   - The shell itself is also Svelte — each island is a
   *     `<div use:island={{…}} />`; the action attaches, mounts the worker
   *     tree, and forwards `props` on every update.
   *   - `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.
   *   - Mediation is runes: worker emits write `$state` feeding the
   *     status line.
   *
   * The seven-island React demo lives in index.html / react-shell.html —
   * this page is the small framework-island edition from the docs.
   */
  import { island } from '@atolljs/svelte-island';
  import { connectIslandWorker } from '@atolljs/islands';
  import type { IslandHandle, Mode } from '@atolljs/islands';

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

  // SharedArrayBuffer only exists in cross-origin-isolated contexts — on
  // hosts without COOP/COEP 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';

  /**
   * Four clients = four OS workers running the same script. Unlike the
   * Vue/Solid/Angular demos, Svelte mounts get a client EACH: Svelte
   * schedules render work through ambient `document` resolution, so two
   * mounts sharing one worker can route ops to the wrong instance.
   */
  const counterClient = connectIslandWorker({ worker: svelteWorker, doorbell: isolated });
  const counter2Client = connectIslandWorker({ worker: svelteWorker, doorbell: isolated });
  const notesClient = connectIslandWorker({ worker: svelteWorker, doorbell: isolated });
  // The 1M-incidents benchmark gets its own worker — the whole point is
  // the heavy component never contends with (or blocks) anything else.
  const incidentsClient = connectIslandWorker({ worker: svelteWorker, doorbell: isolated });

  /* ── Mediation state — worker emits → $state → status line ── */

  let status = $state('mounting islands…');
  let mode = $state<Mode>(initialMode);
  let pids = $state<Record<string, string>>({});
  /** Bump counter — re-render trigger for the aggregate stats read. */
  let statsTick = $state(0);

  /** Every mounted island's handle — setMode + the aggregate stats read them. */
  const handles = new Map<string, IslandHandle>();

  const ready = (key: string) => (handle: IslandHandle): void => {
    handles.set(key, handle);
    handle.setMode(mode); // applies the user's pick to late-mounting islands
    pids = { ...pids, [key]: `worker ${handle.pid}` };
    statsTick++;
  };
  const bump = (): void => {
    statsTick++;
  };
  const setMode = (next: Mode): void => {
    mode = next;
    for (const handle of handles.values()) handle.setMode(next);
    bump();
  };
  const stats = $derived.by(() => {
    void statsTick; // tracked — recompute after every 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: ${mode} · flush calls: ${flushes} · ops applied: ${ops}`;
  });

  const counterOpts = (label: string, key: string, client: typeof counterClient) => ({
    client,
    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')
        status = `${p.label} counter → ${p.count} (Svelte state stayed in the worker)`;
    },
  });
</script>

<h1>Svelte islands — Svelte in the worker</h1>
<p style="font: 12px monospace; color: #9aa4b2; margin-top: -8px">
  registry worker, one client per island — the worker bundle carries Svelte, the shell is a thin
  <code>use:island</code> 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={mode === 'push'}
      disabled={!isolated}
      onclick={() => setMode(mode === 'push' ? 'poll' : 'push')}
    />
    push (SAB doorbell)
  </label>
  <span id="transport-stats">{stats}</span>
</div>
<div id="status-line">{status}</div>

<section class="island">
  <div class="island-head"><span>app: counter (Svelte mount())</span><span class="badge">{pids.counter ?? 'worker …'}</span></div>
  <div class="island-root" use:island={counterOpts('alpha', 'counter', counterClient)}></div>
</section>

<section class="island">
  <div class="island-head"><span>app: counter — second instance (own worker, same registry)</span><span class="badge">{pids.counter2 ?? 'worker …'}</span></div>
  <div class="island-root" use:island={counterOpts('beta', 'counter2', counter2Client)}></div>
</section>

<section class="island">
  <div class="island-head"><span>app: notes (registry app — own worker, same script)</span><span class="badge">{pids.notes ?? 'worker …'}</span></div>
  <div
    class="island-root"
    use:island={{
      client: notesClient,
      app: 'notes',
      props: { title: 'svelte island' },
      onReady: ready('notes'),
      onActivity: bump,
      onEvent: (name, payload) => {
        const p = payload as { text?: string; total?: number };
        if (name === 'noteAdded') status = `notes island emitted noteAdded → "${p.text}" (${p.total} total)`;
      },
    }}
  ></div>
</section>

<section class="island">
  <div class="island-head"><span>app: incidents — 1,000,000 rows, virtualized (own worker)</span><span class="badge">{pids.incidents ?? 'worker …'}</span></div>
  <div
    class="island-root"
    use:island={{
      client: incidentsClient,
      app: 'incidents',
      onReady: ready('incidents'),
      onActivity: bump,
      onEvent: (name, payload) => {
        const p = payload as { start?: number; end?: number; ms?: number };
        if (name === 'rendered') status = `incidents island rendered rows ${p.start?.toLocaleString()}–${p.end?.toLocaleString()} in ${p.ms?.toFixed(1)}ms (of 1,000,000)`;
      },
    }}
  ></div>
</section>

The worker side — Svelte running in the worker

The worker bundle carries Svelte itself — the framework's own renderer drives the proxy DOM and state stays worker-side.defineSveltePolyWorker 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/svelte.worker.tsfrontend
/**
 * Svelte island worker — a registry worker serving Svelte 5 components
 * through `svelteIslandApp` (`mount()` bound to the instance's proxy
 * document). Its bundle carries Svelte but no React — the point of the
 * demo: islands are framework-agnostic over one op protocol.
 *
 * Components are real `.svelte` files — vite-plugin-svelte compiles them
 * for the worker bundle just like a client bundle; runes and `{#if}` /
 * `{#each}` blocks work against the proxy DOM (comment anchors included).
 */
import { defineSveltePolyWorker } from '@atolljs/svelte-island/worker';
import Counter from './svelte/Counter.svelte';
import Incidents from './svelte/Incidents.svelte';
import Notes from './svelte/Notes.svelte';

export const svelteWorker = defineSveltePolyWorker({
  apps: { counter: Counter, notes: Notes, incidents: Incidents },
});
examples/react-dom-worker/src/worker/svelte/Counter.sveltefrontend
<script lang="ts">
  /**
   * 'counter' — the docs' canonical Svelte island: a `label` wire prop
   * ($props), a local `$state` count mutated by a delegated onclick, and an
   * 'incremented' emit for the shell's status line.
   */
  import { emit } from '@atolljs/svelte-island/worker';

  let { label = 'count' }: { label?: string } = $props();
  let count = $state(0);
  const bump = (): void => {
    count += 1;
    emit('incremented', { count, label });
  };
</script>

<div class="svelte-counter">
  <span class="vanilla-heading">{label}: {count}</span>
  <button class="mw-btn" onclick={bump}>increment</button>
</div>
examples/react-dom-worker/src/worker/svelte/Incidents.sveltefrontend
<script 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'.
   */
  import { emit, runInInstance } from '@atolljs/svelte-island/worker';
  import { getActiveInstance } from '@atolljs/islands/worker';

  let { count = 1_000_000 }: { count?: number } = $props();

  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];

  let start = $state(0);
  let lastMs = $state(0);
  let t0 = performance.now();

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

  const onScroll = (e: Event): void => {
    t0 = performance.now();
    // The driver stamps the scroller's scrollTop onto the wire payload —
    // the proxy element's geometry getters are stubs.
    const st = (e as Event & { scrollTop?: number }).scrollTop ?? 0;
    start = Math.max(0, Math.floor(st / ROW_H) - OV);
  };

  // After every commit that touched the window, report the re-render cost.
  // $effect runs async after the event dispatch released the instance
  // scope — capture this mount's key during setup (inside the mount task)
  // and re-enter it for the emit.
  const scope = getActiveInstance();
  $effect(() => {
    void rows; // track the window
    lastMs = performance.now() - t0;
    runInInstance(scope, () => emit('rendered', { start: first, end, ms: lastMs }));
  });
</script>

<div class="incidents">
  <div class="inc-stats">
    {count.toLocaleString()} incidents · rows {first.toLocaleString()}–{end.toLocaleString()} ·
    worker re-render {lastMs.toFixed(1)}ms
  </div>
  <div class="inc-viewport" onscroll={onScroll}>
    <div class="inc-spacer" style:height="{count * ROW_H}px">
      {#each rows as r (r.id)}
        <div 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>
      {/each}
    </div>
  </div>
</div>

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).
  • worker/client/app are mount-stable — swap them through a {#key} block.
  • Props arriving while the mount is in flight coalesce (last write wins) and flush once the handle lands — nothing is dropped.
  • Event dispatches end with a flushSync() worker-side, so state updates land in the dispatch's own op batch.
  • The shell page is an ordinary Svelte 5 component — the only island-specific piece is the action options object.