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

@atolljs/islands/worker — defineWorkers.ts
// 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

packages/<fw>-island/src/worker.ts
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() then docForInstance() — see docForRender in packages/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 .value is the current handler — prop diffs swap the closure without re-pushing a listen op. Modifier changes (capture/once/passive) are a different listener: detach and re-attach with new opts.
  • Form props are property writes. value/checked/disabled are reflected accessors on the proxy — write the property, don't setAttribute, or the shadow state and wire ops diverge.
  • Namespaces. Route svg/mathml through createElementNS; the driver tracks them.
  • update() replaces props. Merge semantics keep stale keys alive — Vue's adapter cloneVNodes then overwrites vnode.props wholesale 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 by JSON.stringify identity — the serialized form is the honest equality since props cross the wire serialized anyway.
  • Mount-stable inputs. client/worker/app/slots are 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

packages/<fw>-island/package.json
{
  "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 rendererpackages/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 + DIpackages/angular-island — setInput + manual CD
needs a full reconciler host configpackages/react-island — hardest: hostConfig + instance records + sync/flush lanes