place

SSR & islands hydration

place renders pages on the server and hydrates only the parts that need to be interactive. The protocol is built around three guarantees: first paint is real content, pages without interactivity ship zero framework JS, and each island's bundle is integrity-pinned and scoped to the marker the server emitted for it.

The lifecycle

ts
// Server (Bun.serve) // ─────────────────── // 1. request hits serve() // 2. router matches a Page // 3. load() runs (if any) — returns serializable data // 4. view() renders → HTML string // └── encountered island markers tracked into a per-page set // 5. response: HTML // + one shared client runtime <script> (per-route splitting) // + one tiny <script src="/islands/<name>-<sig>.js"> per island // Client // ────── // 1. browser parses HTML — links, forms, CSP, theme already live // 2. island bundles fetch in parallel (deferred), each carries its own // auto-mount wrapper; SRI integrity guards each bundle // 3. each wrapper scans for [data-view="island"][data-view-id="<name>"] // markers, reads data-view-props, hydrates the impl into the marker // 4. _setHydrated(true) flips once any island mounts — onMount() // callbacks fire on schedule, signals attach to existing DOM nodes // // Pages with NO islands ship ZERO framework JS.

The whole-page tree runs once on the server. On the client, only islands rehydrate — each island's auto-mount wrapper scans for its marker, reads the serialized props, and mounts the impl into the existing DOM nodes. There is no virtual DOM, no reconciler walk over the document, and no whole-page boot step.

Authoring an island

Islands are typed JSX components, not string directives. Author shape is one line: island(import.meta.url, fn). The framework's Bun plugin rewrites every island(fn) to island(import.meta.url, fn) at load so the bundler can locate the source; you never type the URL boilerplate.

ts
// An island is just a function. `island` and `state` are auto- // imported by the framework's Bun plugin; you write zero ceremony. const Counter = island(import.meta.url, () => { const n = state(0) return ( <button onClick={() => n.set(n() + 1)}> count: {n} </button> ) }) // Used like any other component anywhere in the tree: <Counter /> // hydrates on load (default) <Counter client="visible" /> // hydrates on IntersectionObserver <Counter client="idle" /> // hydrates on requestIdleCallback <Counter client="interaction" /> // hydrates on first hover/focus // At build time the plugin rewrites `island(fn)` to // `island(import.meta.url, fn)` so the bundler knows which module // the impl lives in. The user never types the URL boilerplate.
No 'use client', no magic strings
The island boundary is a typed function call discovered statically through import.meta.url at build time — no compiler scan for special string directives. See ADR 0019 for the rationale behind typed markers over string directives.

The wire format

SSR emits a unified data-view-* marker around each island's output. The marker is the contract between SSR and the auto-mount wrapper — the wrapper queries for its name, reads the props, and attaches.

html
<!-- Server emits a typed marker, NOT a virtual-DOM hash. --> <div data-view="island" data-view-id="counter" data-view-props='{"start":0}' data-view-strategy="visible" > <!-- SSR'd content (the same view() output) lives inside. --> <button>count: 0</button> </div> <!-- One auto-mount script per island name, per page: --> <script src="/islands/counter-aB3xK9pQrS_e.js" integrity="sha384-..." type="module" defer ></script>

The bundle URL includes a signature suffix (a 12-char prefix of the SHA-384 content hash) so prod deploys cache-bust cleanly and dev HMR can tell whether a swap is shape-compatible. The integrity="sha384-..." attribute pins the exact bytes — encoded once, hashed, and served as the same Uint8Array, so sourcemap-bearing dev builds cannot drift from their declared hash.

Streaming with suspense()

Long-running renders ship a fallback first, then stream the resolved content in. The swap uses an HTML comment-marker pair as anchors; a tiny inline script replaces the placeholder when each chunk arrives. This works before any island bundle has loaded.

ts
// suspense() takes ONE options object: { fallback, children, on }. // It emits a comment-marker pair around its content — the fallback // ships in the initial HTML; once every resource in `on` resolves, // the framework streams a swap chunk that replaces the placeholder // anchors. Works pre-hydration; no client JS required for the swap. import { suspense } from '@place/component' import { resource } from '@place/reactivity' const article = resource( (signal) => fetch(`/api/articles/${id}`, { signal }).then((r) => r.json()), { hydrationKey: `article:${id}` }, ) view: () => ( <section> <h1>Article</h1> {suspense({ fallback: <Skeleton />, on: [article], children: () => { const s = article.status() return s.state === 'ready' ? <ArticleBody data={s.value} /> : null }, })} </section> )

ISR (incremental static regeneration) is built on the same primitive plus a typed cache store. See Recipes: Data fetching for the load() + revalidate revalidation pattern.

No client/server directives

Server-only code lives in named fields with server-only types. The framework's Bun plugin strips them from the client bundle alongside the build-time __PLACE_BROWSER__ define.

ts
// place has no 'use client', no 'use server', no string directives. // The split is structural — typed at the call site: // // • island(fn) — typed wrapper, JSX-callable // • action({ ... handler }) — server-only fn with typed body // • load: ({ params }) => { ... } — typed page field // // The Bun plugin strips server-only branches via the // __PLACE_BROWSER__ build define; load + on: + cap providers never // reach the client. The classifier (build-time) reads effect brands // off the inferred types and picks the smallest possible runtime // per view — static / thaw / island / island+stream. export default page('/post/:id', { load: async ({ params }) => ({ post: await db.find(params.id) }), view: ({ post }) => <Article post={post} />, on: { delete: async (_, { params }) => db.delete(params.id), // server-only }, })

The build-time classifier

Every island(import.meta.url, ...) call is classified at build time. The classifier reads effect brands off the inferred types — state() is 'state', watch()/onMount() are 'lifecycle', Suspense is 'suspense' — and picks the smallest runtime that satisfies the body:

  • static — no effects beyond pure; ships 0 bytes of JS for this view
  • thaw — state-only; ships ~300 B of inline action AST + a shared ~1.5 KB runtime (Tier 9 emits this; today it falls through to island)
  • island — lifecycle or DOM effects; per-island chunk + auto-mount wrapper
  • island+stream — reads from an unresolved Suspense's resource; the chunk subscribes to the Channel B state envelope

The picks are surfaced as a compact report at serve() startup, persisted to dist/.place/island-entries/view-manifest.json, and (in dev) clickable in the error overlay. The classifier is observable, not opaque.

Why no hydration-mismatch warnings

Frameworks that use a virtual-DOM reconciler at hydration compare server HTML to a fresh client render and warn (or worse, blow up) on differences. place doesn't do that — the SSR'd HTML is the post-hydration DOM. Signal subscriptions attach to existing nodes; event handlers attach to existing elements. If the server-rendered output is wrong, the client output is equally wrong; there's no second source of truth to diff against.

For accidental divergence (e.g., Date.now() at the top of an island's view that renders differently per request), the dev hydration auditor logs attribute-level diffs scoped to each marker.

What about whole-page interactivity?

Reach for an island. Anything that needs a click handler, a watch, a cookie-bound signal, or a third-party widget that touches document in its constructor goes inside an island(import.meta.url, ...) call. The framework will discover it, chunk it, mount it without you wiring anything else. Pages that contain none ship none.