stoneware

Documentation

Islands

How a component earns its JavaScript, and what hydration actually does.

An island is a subtree that owns its own interactivity. Everything outside it stays inert HTML forever, which means it costs nothing to send and nothing to run.

islands/Counter.tsxtsx
import { signal } from "stoneware/signals";

const count = signal(0);

export default function Counter() {
  return (
    <button onClick={() => count.value++}>
      Clicked {count} times
    </button>
  );
}

What happens on the server

  • The island renders to HTML with its initial state, so there is no flash of empty content.
  • Its root element is tagged with a hydration marker.
  • Its props are serialized into a non-executable JSON block.
  • One module script per distinct eagerly-hydrated island is added before </body>.

That last line says eagerly for a reason: an island can be told to wait. See when islands hydrate for the client:visible, client:idle and client:media directives.

An island must render exactly one HTML element at its root, because that element carries the marker. Stoneware raises an explicit error rather than mis-hydrating.

Updates without a reconciler

Changing a signal does not re-run the component. The subscription is attached to the exact text node or attribute that depends on it, so the update writes one value. There is no virtual DOM and nothing to diff.

Sharing state between islands

Export a signal from a module and import it in more than one island. They compile to separate bundles, but the bundler hoists the shared module into a common chunk, so both observe the same instance.

lib/state.tstsx
import { signal } from "stoneware/signals";

export const subscriberCount = signal(1284);

Import signals from stoneware/signals

Not from @preact/signals-core directly, and do not add it to your package.json. It is already a dependency of the framework, and stoneware/signals is a thin re-export of exactly the same module — the indirection exists so the dependency stays swappable without a breaking change to every island.

Installing it yourself at a version outside the range the framework resolved leaves two copies in node_modules, and the two produce signals that are not instances of each other's class. From 0.1.7 the framework recognises a signal by the brand the library puts on it, which is the same across copies, so this is handled rather than fatal. On 0.1.6 and earlier it is fatal, and confusingly so:

what two copies used to producetxt
TypeError: Cannot render an instance of a.
  in <span>
  in <QuoteBadge>
"An instance of a" is a minified class name from inside a dependency, reported against a component that is correct. Recognising the brand instead of the class removes the whole failure — but one copy is still the right number, and one import path is how you get it.

Something wrong in the framework itself rather than the page? Open an issue on GitHub.