Skip to content

Reference

Constraints & patterns

What crosses the island boundary and what does not. Each rule is paired with the mistake it prevents and the fix.

On this page

Islands are a real boundary. Knowing what crosses it, and what quietly does not, keeps you out of trouble. Each rule below is the constraint plus the mistake people hit first, and the way around it.

Props cross. Children and snippets do not.

Props are serialized with devalue and cross into the island. Host children, snippets, and callbacks do not. They live in the host scope, not the island’s. You cannot hand an island a snippet or a function from the page.

<!-- ✗ the island never sees this snippet or callback -->
<Island onselect={handleSelect}>
  {#snippet row(item)}<Row {item} />{/snippet}
</Island>

Model the interaction with props and remote functions, or move the UI inside the island’s own .svelte file.

The one reserved exception is ogygiaFallback on a server island. See there for its handling.

Captured host state is a snapshot

A value you read from the host into island markup is a serialized snapshot, not live state. Mutating it updates nothing.

<!-- ✗ writes to a serialized snapshot; nothing updates -->
<button onclick={() => count++}>{count}</button>
<!-- ✓ own the state inside the island -->
<script>
  let count = $state(props.initial);
</script>
<button onclick={() => count++}>{count}</button>

ogygia stops the mistake for you two ways:

GuardWhenWhat
Build errorcompilecaptured-variable writes in island markup fail the build
Dev proxyruntimea deep proxy throws on mutation (dev only)

Pass data as props; own mutable state inside the island.

Page-level lifecycle is dead code

On a csr = false page, the page module never runs on the client. Top-level onMount, effects, and event listeners in the page do nothing.

<!-- ✗ in a csr = false page, this never fires -->
<script>
  onMount(() => startPolling());
</script>

If you reach for onMount, you actually want an island. Move anything that needs to run in the browser inside one. For init, telemetry, or listeners that render no UI, that is a boot island: a headless component whose whole job is its $effect.

Inline <script> tags in page markup are a related trap: they run once, on first load, not per navigation. The router swaps <body> without executing them. Per-navigation code belongs in an island. See the router for why.

Reach for an island, not a bare dynamic import

If you find yourself hand-rolling import() + mount to lazy-load a component, stop.

// ✗ hand-rolled lazy mount: no SSR HTML, no signed boundary
const { default: Chart } = await import('./Chart.svelte');
mount(Chart, { target, props });

That is exactly what wake: 'idle' and wake: 'visible' already do, with SSR HTML first and a signed boundary around it. Use the island.

Choose the boundary before you build

If a subtree needs interactivity, it is an island. If it does not, it is a lake or plain page HTML. Do not wrap static content in an island to “be safe”. That just ships JS you did not need.

Decide up front:

NeedBoundary
Interactiveisland
Static but heavylake
Request-dependent HTMLserver island

Retrofitting the boundary later is the expensive path.

Dev is not prod

Two behaviors differ in dev, and both can hide bugs until you ship:

  • SSR query seeding degrades to a client refetch.
  • The mutation-guard proxy only runs in dev.

Something that works in dev may behave differently in production. Always sanity-check data flows against a production build before shipping.