Custom island renderers
A @atolljs/<fw>-island package has two halves: a worker adapter that teaches your framework's renderer to draw into an island's proxy document, and a shell surface that mounts worker islands as ordinary elements in a main-thread app. The op protocol, event dispatch, and doorbell all live in @atolljs/islands — you never touch them.
The contract: RenderedIslandApp
// What your adapter produces — the registry value shape:
interface RenderedIslandApp {
mount(ctx: RenderContext): RenderedHandle | void;
}
interface RenderContext {
instance: string; // wire key — scopes emit() and ops
doc: ProxyDocument; // mutations here already emit ops
props: Record<string, unknown>; // the serialized mount props
}
interface RenderedHandle {
update?(props): void; // fine-grained patch; omit → clear+remount fallback
sync?(fn): void; // run fn in your sync-commit lane (React: flushSync)
flush?(): void; // drain work scheduled outside tasks (effects)
dispose?(): void; // teardown — runs BEFORE the proxy doc dies
}mount renders the component's output into ctx.doc — every proxy mutation serializes to ops on its own, so the adapter is only a bridge between your framework's host-op interface and the proxy DOM facade. Return a handle for fine-grained updates; omit update and the runtime falls back to dispose + clear + remount.
Worker adapter skeleton
import { createRenderer /* or mount(), RendererFactory2, … */ } from '<fw>';
import {
defineMonoWorker, definePolyWorker, islandApp,
getActiveInstance, getLastActiveInstance, getLastTouchedInstance,
docForInstance,
} from '@atolljs/islands/worker';
// Consumers shouldn't need a second specifier for the shell channel:
export { emit, runInInstance } from '@atolljs/islands/worker';
const { render, createApp } = createRenderer<ProxyNode, ProxyElement>({
createElement: (tag) => docForRender().createElement(tag),
insert: (el, parent, anchor) => parent.insertBefore(el, anchor ?? null),
patchProp: (el, key, _prev, next) => { /* class/style/events/attrs */ },
/* …the rest of your framework's host-op surface… */
});
export function fwIslandApp(Component): RenderedIslandApp {
return {
mount({ doc, props }) {
const app = createApp(Component, props);
app.mount(doc.body);
return {
update: (next) => /* re-render root with REPLACED props */,
dispose: () => app.unmount(),
};
},
};
}
// Stamped registry value + convenience define* wrappers:
export const fwIsland = (name, C) => islandApp(name, fwIslandApp(C));
export function defineFwPolyWorker({ apps, sharedMemory }) {
return definePolyWorker({
apps: Object.fromEntries(
Object.entries(apps).map(([k, c]) => [k, fwIslandApp(c)])),
sharedMemory,
});
}
export const defineFwMonoWorker = (c, opts) =>
defineMonoWorker(fwIslandApp(c), opts);Pitfalls the existing adapters hit
- Realm resolution. Module-level renderers call your create* host ops with no node argument — the instance isn't in scope. Resolve the document through the chain
getActiveInstance() || getLastActiveInstance() || getLastTouchedInstance()thendocForInstance()— seedocForRenderinpackages/vue-island/src/worker.ts. This keeps async scheduler flushes (microtask commits after the dispatch task returns) on the right instance. - Event invokers. Attach one stable invoker per (element, event) whose
.valueis the current handler — prop diffs swap the closure without re-pushing alistenop. Modifier changes (capture/once/passive) are a different listener: detach and re-attach with new opts. - Form props are property writes.
value/checked/disabledare reflected accessors on the proxy — write the property, don'tsetAttribute, or the shadow state and wire ops diverge. - Namespaces. Route
svg/mathmlthroughcreateElementNS; the driver tracks them. - update() replaces props. Merge semantics keep stale keys alive — Vue's adapter cloneVNodes then overwrites
vnode.propswholesale for exactly this reason. - Instance-less work needs a scope. Timers and promise continuations mutating DOM must wrap in
runInInstance(instance, fn)+bumpOpsVersion(), or their ops flush to the wrong queue — or never.
The shell half
Main-thread side is a useIsland-equivalent returning { host, handle, status, error } — the template is packages/vue-island/src/index.ts:
- Mount on non-null host element via
mountIsland({ client, el, app, props, onEvent, slots }); destroy + remount if the element swaps (v-if / conditional remount). - Reactive props →
handle.updateProps, deduped byJSON.stringifyidentity — the serialized form is the honest equality since props cross the wire serialized anyway. - Mount-stable inputs.
client/worker/app/slotsare read once — document "swap via key" rather than mid-life remount. - Generation-guard the async mount. A mount that resolves after teardown destroys its handle immediately instead of attaching a zombie island.
- Scope disposal destroys. Component unmount / effect-scope stop →
island.destroy(); shared clients keep the worker alive for other mounts. - The component wrapper is thin. It reads framework props reactively and forwards through the options object, so fresh closures never remount the worker.
Package shape
{
"name": "@atolljs/<fw>-island",
"exports": {
".": "./src/index.ts", // shell surface — no framework renderer here
"./worker": "./src/worker.ts"
},
"peerDependencies": {
"@atolljs/core": "0.1.3",
"@atolljs/islands": "0.1.3",
"<fw>": "^x.y.z"
}
}The /worker split is the bundle boundary: a registry worker that only mounts other frameworks never parses yours, so framework imports belong strictly in the worker entry.
Pick your starting point
| If your framework… | Copy |
|---|---|
has a custom-renderer API (createRenderer-style host ops) | packages/vue-island — smallest, single file |
| renders via fine-grained signals / universal renderer | packages/solid-island — per-key signal props box for update() |
| compiles to imperative DOM calls with a programmatic mount() | packages/svelte-island — $state props box |
| abstracts the DOM behind a Renderer2-style interface + DI | packages/angular-island — setInput + manual CD |
| needs a full reconciler host config | packages/react-island — hardest: hostConfig + instance records + sync/flush lanes |