Solid — islands
@atolljs/solid-island — a worker-hosted tree mounted as an ordinary element in a Solid shell. The worker's render loop produces serialized DOM ops; the main thread just replays them.
Live demo — Solid in the worker
Four islands, Solid 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 Island() host from @atolljs/solid-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), Solidre-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 Solid API
| Export | Signature | What it does |
|---|---|---|
Island | Island(options): HTMLElement | Component form — returns the host element directly, so it works with or without JSX. In JSX it's <Island app worker props onEvent/>; called directly it hands back a div to insert. |
createIsland | createIsland(options): { ref, handle, status, error, updateProps } | The composable — wire ref to a container and the owner tree owns the lifecycle. props accepts an accessor for reactive pushes. |
defineSolidPolyWorker | defineSolidPolyWorker({ apps }) | Registry worker — apps render through solid-js/universal, so signal writes produce minimal op batches. |
defineSolidMonoWorker | defineSolidMonoWorker(Component) | Instance worker — the 1:1 topology, mounted namelessly. |
solidIslandApp / emit | solidIslandApp(Component) / emit(name, payload) | The adapter for shared framework-agnostic registries, and the island → shell event channel. |
Mounting — the shell is a thin Solid 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 an accessor — props: () => ({…}) is tracked, so signal reads push updateProps fine-grained, deduped by serialized identity.
/**
* The Solid islands demo — SOLID RUNNING INSIDE WORKERS.
*
* Each island below mounts an app from `worker/solid.worker.ts`'s
* registry: plain-function Solid components (createSignal + h()/insert,
* generate:'universal') rendered by Solid's universal renderer against
* the proxy DOM. The worker bundle carries Solid; this page is just a
* thin host that replays its ops.
*
* What changes versus shell.tsx:
* - The shell itself is also Solid — `solid-js/html` tagged templates
* produce real DOM with reactive inserts; `Island()` returns the host
* element itself.
* - `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 signals: worker emits write setters that feed the
* status line; island `props` can be an accessor the binding tracks.
*
* 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 { createRoot, createSignal } from 'solid-js';
import html from 'solid-js/html';
import { Island } from '@atolljs/solid-island';
import { connectIslandWorker } from '@atolljs/islands';
import type { IslandHandle, Mode } from '@atolljs/islands';
/** The registry worker — one script serving all Solid apps. */
const solidWorker = (): Worker =>
new Worker(new URL('./worker/solid.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';
/**
* Three clients = three OS workers running the same script. The counters
* share one client (one worker, two 'counter@N' instances); notes and the
* incidents benchmark each get their own.
*/
const counterClient = connectIslandWorker({ worker: solidWorker, doorbell: isolated });
const notesClient = connectIslandWorker({ worker: solidWorker, 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: solidWorker, doorbell: isolated });
/* ── Shell ──────────────────────────────────────────────────────────────── */
const rootEl = document.getElementById('root');
if (rootEl) {
// One owner for every signal/effect/island — Solid islands dispose with
// their owner, so the whole page tears down cleanly as a unit.
createRoot(() => {
const [status, setStatus] = createSignal('mounting islands…');
const [mode, setModeState] = createSignal<Mode>(initialMode);
const [pids, setPids] = createSignal<Record<string, string>>({});
/** Bump counter — re-render trigger for the aggregate stats read. */
const [statsTick, setStatsTick] = createSignal(0);
const bump = (): void => {
setStatsTick((t) => t + 1);
};
/** 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
setPids((p) => ({ ...p, [key]: `worker ${handle.pid}` }));
bump();
};
const setMode = (next: Mode): void => {
setModeState(next);
for (const handle of handles.values()) handle.setMode(next);
bump();
};
/** One island panel — head + the `Island()` host element. */
const panel = (title: string, badge: () => string | undefined, el: HTMLElement): Node =>
html`<section class="island">
<div class="island-head"><span>${title}</span><span class="badge">${badge}</span></div>
${el}
</section>` as Node;
const island = (opts: Parameters<typeof Island>[0]): HTMLElement => {
const el = Island(opts);
el.className = 'island-root';
return el;
};
const counter = (label: string, key: string): HTMLElement =>
island({
client: counterClient,
app: 'counter',
props: { label },
onReady: ready(key),
onActivity: bump,
onEvent: (name, payload) => {
const p = payload as { count?: number; label?: string };
if (name === 'incremented')
setStatus(`${p.label} counter → ${p.count} (Solid state stayed in the worker)`);
},
});
const transportBar = html`<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"
onClick=${() => setMode(mode() === 'push' ? 'poll' : 'push')}
/>
push (SAB doorbell)
</label>
<span id="transport-stats">${() => {
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}`;
}}</span>
</div>` as HTMLElement;
// The checkbox is the only mode control, so its native state stays in
// sync — just seed the initial mode and the isolation fallback.
const pushToggle = transportBar.querySelector('#push-toggle') as HTMLInputElement;
pushToggle.checked = isolated;
pushToggle.disabled = !isolated;
rootEl.append(
html`<h1>Solid islands — Solid in the worker</h1>` as Node,
html`<p style="font: 12px monospace; color: #9aa4b2; margin-top: -8px">
registry worker + shared client — the worker bundle carries Solid, the shell is a thin
<code>Island()</code> host (<code>solid-js/html</code> templates).
<a href="./index.html" style="color: #7fb6ff">framework-free shell →</a>
</p>` as Node,
transportBar,
html`<div id="status-line">${status}</div>` as Node,
panel(
'app: counter (Solid universal renderer)',
() => pids().counter,
counter('alpha', 'counter'),
),
panel(
'app: counter — second instance (SAME worker as the first, one client)',
() => pids().counter2,
counter('beta', 'counter2'),
),
panel(
'app: notes (registry app — own worker, same script)',
() => pids().notes,
island({
client: notesClient,
app: 'notes',
props: { title: 'solid island' },
onReady: ready('notes'),
onActivity: bump,
onEvent: (name, payload) => {
const p = payload as { text?: string; total?: number };
if (name === 'noteAdded')
setStatus(`notes island emitted noteAdded → "${p.text}" (${p.total} total)`);
},
}),
),
panel(
'app: incidents — 1,000,000 rows, virtualized (own worker)',
() => pids().incidents,
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')
setStatus(`incidents island rendered rows ${p.start?.toLocaleString()}–${p.end?.toLocaleString()} in ${p.ms?.toFixed(1)}ms (of 1,000,000)`);
},
}),
),
);
});
}The worker side — Solid running in the worker
The worker bundle carries Solid itself — the framework's own renderer drives the proxy DOM and state stays worker-side.defineSolidPolyWorker serves the whole apps map from one script; islands mount by registry name. See the Islands page for modes, slots, and lifecycle.
/**
* Solid island worker — a registry worker serving hand-authored Solid
* components through `solidIslandApp` (Solid's universal renderer bound to
* the instance's proxy DOM). Its bundle carries Solid but no React — the
* point of the demo: islands are framework-agnostic over one op protocol.
*
* Components are plain functions built on `h()`/`insert` (generate:
* 'universal'); `solid-js` is aliased to its client build at bundle time —
* `mount` would pull SSR entry points.
*/
import { createSignal, mapArray } from 'solid-js';
import {
defineSolidPolyWorker,
emit,
h,
insert,
} from '@atolljs/solid-island/worker';
/**
* 'counter' — the docs' canonical Solid island: a `label` wire prop, a
* `createSignal` count, a tracked `insert` that re-evaluates on click, and
* an 'incremented' emit for the shell's status line.
*/
function Counter(props: Record<string, unknown>): ReturnType<typeof h> {
const [count, setCount] = createSignal(0);
const label = h('span', { class: 'vanilla-heading' });
insert(label, () => `${props.label ?? 'count'}: ${count()}`);
const bump = h(
'button',
{
class: 'mw-btn',
onClick: () => {
const n = count() + 1;
setCount(n);
emit('incremented', { count: n, label: props.label });
},
},
'increment',
);
return h('div', { class: 'solid-counter' }, label, bump);
}
/**
* 'notes' — the notes composer from the Vue demo, re-done with mapArray:
* `insert(list, mapArray(notes, ...))` renders the signal array into real
* nodes whose identity survives appends.
*/
function Notes(props: Record<string, unknown>): ReturnType<typeof h> {
let draft = '';
const [notes, setNotes] = createSignal<string[]>([]);
const input = h('input', {
placeholder: 'write a note…',
// _enrichEvent stamps the wire payload's `value` onto the target.
onInput: (e: Event) => {
draft = (e.target as HTMLInputElement).value;
},
});
const add = (): void => {
const text = draft.trim();
if (text === '') return;
setNotes([...notes(), text]);
(input as unknown as HTMLInputElement).value = '';
draft = '';
emit('noteAdded', { text, total: notes().length });
};
input.addEventListener('keydown', (e) => {
if ((e as { key?: string }).key === 'Enter') add();
});
const list = h('ul', { class: 'vanilla-log' });
insert(list, mapArray(notes, (n) => h('li', { class: 'vanilla-log-line' }, n)));
const readout = h('div', { class: 'vanilla-readout' });
insert(
readout,
() => `${notes().length} note(s) — state lives in the worker`,
);
return h(
'div',
{ class: 'solid-notes' },
h('h3', { class: 'vanilla-heading' }, String(props.title ?? 'solid island')),
h('div', { class: 'atoll-map-places' }, input, h('button', { class: 'atoll-map-place-btn', onClick: add }, 'add')),
list,
readout,
);
}
/**
* '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 — nothing is materialized until it's visible;
* each scroll event re-evaluates the window and a 'rendered' emit reports
* the re-render time back to the shell.
*/
const REGIONS = ['us-east', 'us-west', 'eu-central', 'ap-south', 'sa-east'];
const SEVS = ['P1', 'P2', 'P3', 'P4'];
const ROW_H = 24;
const VIEW = 320;
const OV = 4;
const VISIBLE = Math.ceil(VIEW / ROW_H) + OV * 2;
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];
function Incidents(props: Record<string, unknown>): ReturnType<typeof h> {
const total = (): number => Number(props.count ?? 1_000_000);
const [start, setStart] = createSignal(0);
const [lastMs, setLastMs] = createSignal(0);
let t0 = performance.now();
const spacer = h('div', {
class: 'inc-spacer',
style: `height:${total() * ROW_H}px`,
});
insert(spacer, () => {
const s = Math.min(start(), Math.max(0, total() - VISIBLE));
const n = Math.min(VISIBLE, total() - s);
const rows = Array.from({ length: n }, (_, k) => {
const r = incident(s + k);
return h(
'div',
{ class: 'inc-row', style: `top:${r.id * ROW_H}px` },
h('span', { class: 'inc-id' }, `#${r.id}`),
h('span', { class: 'inc-site' }, r.site),
h('span', { class: 'inc-region' }, r.region),
h('span', { class: sevClass(r.sev) }, `${sevLabel(r.sev)} · ${r.sev}`),
h('span', { class: 'inc-dur' }, r.dur),
);
});
const ms = performance.now() - t0;
setLastMs(ms);
emit('rendered', { start: s, end: s + n - 1, ms });
return rows;
});
const stats = h('div', { class: 'inc-stats' });
insert(
stats,
() =>
`${total().toLocaleString()} incidents · rows ${Math.min(
start(),
Math.max(0, total() - VISIBLE),
).toLocaleString()}–${Math.min(
start() + VISIBLE - 1,
total() - 1,
).toLocaleString()} · worker re-render ${lastMs().toFixed(1)}ms`,
);
return h(
'div',
{ class: 'incidents' },
stats,
h(
'div',
{
class: 'inc-viewport',
onScroll: (e: { scrollTop?: number }) => {
t0 = performance.now();
// The driver stamps the scroller's scrollTop onto the wire
// payload — the proxy element's geometry getters are stubs.
setStart(Math.max(0, Math.floor((e.scrollTop ?? 0) / ROW_H) - OV));
},
},
spacer,
),
);
}
export const solidWorker = defineSolidPolyWorker({
apps: { counter: Counter, notes: Notes, incidents: Incidents },
});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).- The shell demo uses
solid-js/htmltagged templates — real Solid reactivity (insert/effect) with no JSX transform on the shell side. app/worker/clientare mount-stable — remount by rebinding the ref to a different element or via keyed control flow.- Worker-side props arrive as per-key signal getters — updates patch exactly the reads that changed.
- Pin the client build in worker bundles:
resolve: { alias: { 'solid-js': 'solid-js/dist/solid.js' }}— the worker/node export conditions resolve the SSR build where effects never re-run.