How it works

The worker never touches DOM. It renders into a proxy document whose every mutation serializes to an op; the main-thread driver replays those ops onto real nodes and routes real events back. Everything else — instances, emit, callbacks — is built on that one stream.

That engine is the real Atoll: @atolljs/islands + @atolljs/core implement the op protocol, proxy DOM, instance lifecycle, and driver. @preact-mvc/atoll is a thin facade that re-exports them with our transport defaults, and @preact-mvc/atoll-island adds the framework binding.

The op protocol

A small vocabulary covers every DOM effect (Atoll's ops — see docs/atoll/ in the repo for the original architecture docs):

OpEffect on the shell DOM
createAllocate a node id for a tag/text node
textSet a text node's data
append / insertBefore / removeTree structure
attr / styleAttributes and inline style
listenRegister a handler id for an event name on a node
emitWorker → shell named event (lands on onEvent)

The proxy DOM

Inside the worker, document resolves to a ProxyDocument — enough of the DOM surface for Preact to render against (createElement, createTextNode, insertBefore, removeChild, data, innerHTML, style, addEventListener) where every method appends ops to the instance's queue instead of mutating anything.

worker
// Inside the worker, this Preact render:
<button onClick={inc}>count: {n}</button>

// …serializes to roughly:
//   create #1 'button'
//   create #2 text 'count: 0'
//   append #1 <- #2
//   listen #1 'click' handler#7
//   append body <- #1

Instance scoping

Each mounted island is an instance keyed app@N with its own op queue, handler table, and proxy document. Ambient document in worker code resolves through the active-instance dispatcher: inside a Preact render or an event handler, the current instance is tracked explicitly (a render-scope hook tags vnodes), so concurrent islands in one worker never cross streams.

Timer/signal-driven updates happen outside any dispatched task: signal writes re-render the island, the instance re-enters its scope, and the op batch pushes to the shell unsolicited — that's why a worker clock keeps ticking with zero shell involvement.

Events: shell → worker

The driver attaches real listeners for every listen op. A DOM event serializes into a small payload (type, target node id, plus the fields islands care about) and posts to the worker, which looks up the handler id and invokes the Preact listener — the same function the app registered.

Mount lifecycle

connectIslandWorker wraps the worker in a tiny RPC client (mount/updateProps/dispatch/setSize/unmount). mountIsland sends the app name + initial props, replays ops as they stream back, wires ResizeObserver to push size changes into the worker, and enforces a mount timeout so a failed worker surfaces as an error state instead of a blank box.

Transport: poll vs push

Two flush modes decide how op batches leave the worker. mode: 'poll' (the default) is pure postMessage — the driver polls the worker's op queue on a short interval, so nothing but a plain worker entry is needed. mode: 'push' rings a SharedArrayBuffer doorbell for immediate flush, but SAB requires cross-origin isolation headers (Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp) — hosts like GitHub Pages can't serve them, so poll is the default everywhere. Push mode is opt-in per mount: mode: 'push' on <Island>, or doorbell: true on connectIslandWorker.

Contract enforcement

When a worker entry attaches a contract (viewIslandApp(App, { contract })), the wire gets a schema on both ends: mount and updateProps payloads are validated against props before they reach the app, and every declared emit is checked worker-side against events before it goes on the wire. Undeclared or malformed data fails fast in the worker instead of surfacing as a shell-side mystery.

Deep internals — per-instance state, the render-scope tracking, the event-name bridge — live in the repo's docs/islands.md. This page is the consumer-facing model; that doc is the invariant list.