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.
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.
import { signal } from "stoneware/signals";
export const subscriberCount = signal(1284);That instance is per browser tab on the client, which is the point. On the server it is per process — one instance shared by every request that process ever answers, for as long as it runs.
Never assign to a shared signal on the server
Reading one during a server render is safe. Writing one is a cross-user data leak, and it does not announce itself: the page renders, the types check, and the tests pass.
The tempting version is giving an island its starting data by setting the shared signal in the route before returning the tree. Here is what that actually does, measured on a two-route fixture:
export const cart = signal(0); // lib/store.ts cart.value = itemsFor(user); // routes/shop.tsx ← the write GET /shop?user=alice&items=7 -> alice:7 GET /shop -> alice:7 ← someone else's cart GET /shop?user=bob&items=1 -> carol:99 ← concurrent, crossed over GET /shop?user=carol&items=99 -> carol:99
The second request asked for nothing and was served the first visitor's identity and basket. The third and fourth were in flight at the same time, and one rendered the other's data — which is the normal state of a server under any load at all. Nothing in that output is a crash, so nothing draws attention to it.
The leak needs a write. Four requests against a process that only ever reads a shared signal all rendered the same initial value. Sharing a signal between islands is not the hazard; assigning to one while rendering is.
Server data reaches an island through props
Props are per request by construction — they are serialized into that one response's hydration payload and cannot outlive it. That is the mechanism for anything the server knows. Keep the shared signal for what the visitor changes after the page has loaded.
// lib/store.ts — starts neutral, only ever written in the browser
export const cartDelta = signal(0);
// routes/shop.tsx — the server passes what it knows
<CartBadge user={user} items={itemsFor(user)} />
// islands/CartBadge.tsx — server value from props, live changes from the signal
export default function CartBadge({ user, items }) {
return <span>{user}:{items + cartDelta.value}</span>;
}The same four requests through that version render alice:7, anonymous:0, bob:1 and carol:99 — each its own. The shared signal still does its job the moment a visitor adds something, and every island watching it still updates together.
The rule is one line: a module-scope signal is client state that happens to be visible during SSR. If a value differs per visitor, it belongs in props.
This is not specific to signals or to Stoneware — a module-scope Map, array or plain object used the same way leaks the same way, in any server that keeps a process alive between requests. Signals make it easier to reach for, which is why it is written down here.
The dev server watches for it
Because none of the above announces itself, the renderer remembers what each signal held the last time it rendered one and says something when that changes underneath it:
[stoneware] A signal rendered inside <span> changed value between renders: "alice" -> "bob".
A signal declared at module scope is one instance per server process, shared by every
request it answers, so a value written during one render is still there for the next
visitor. If this value differs per visitor, pass it to the island as a prop instead —
props belong to one response and cannot outlive it.
Reported once per signal, in development only.It compares rather than intercepts. Nothing wraps signal() — stoneware/signals is a thin re-export, and wrapping it would put the check in every island's client bundle. The renderer is server-only, so this costs the browser nothing at all and production one boolean check per rendered signal.
- Reported once per signal, not once per request, so a dev reload loop does not fill the terminal.
- Silent on the safe pattern: a shared signal that is only ever read never reports, and neither does a signal created fresh inside a component.
- It needs two renders to see a change, so the first request establishes the baseline and the warning appears on the reload after it.
- It only sees signals that reach the output. A module-scope signal mutated during a render but never rendered is invisible to it — the leak is real, but there is nothing in the HTML to compare.
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:
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.