Skip to content

Reference

Constraints & patterns

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

On this page

An island is a real boundary, and some things cross it while others quietly don’t. Each rule below states the constraint, the mistake people usually run into 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, because they live in the host’s scope rather than the island’s, so you cannot give 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 into the island’s own .svelte file.

The one reserved exception is ogygiaFallback on a server island, which that page describes.

Captured host state is a snapshot

A value from the host that you read into island markup is a serialized snapshot rather than live state, so 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 catches this mistake in two ways:

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

Pass data in as props, and keep mutable state inside the island.

Store reads are snapshots too

A $store auto-subscription read inside a snippet that crosses an island boundary is captured the same way. The compiler evaluates the subscription in the host, where the $ syntax is legal, passes the value across as a plain serialized prop, and rewrites the snippet’s copy to use it. The build warns and traces each such read. In ordinary Svelte $store is live, but across the boundary it is a snapshot frozen at render time, and later store updates never reach the island.

{#snippet children()}
  <!-- captured as the VALUE of $country at render time (build warning points here) -->
  <span>{$country}</span>
{/snippet}

To make the snapshot explicit (and silence the warning), read the store in the host scope and use the plain value inside the snippet. The store object itself cannot cross, because the functions of a store declared in the host do not serialize, and the compiler warns about that too. For live state shared across the boundary, use a wired class.

Page-level lifecycle is dead code

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

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

When you reach for onMount, what you actually want is an island, so move everything that has to run in the browser into one. For initialization, telemetry or listeners that render no UI, use a boot island, a headless component whose only job is its $effect.

Inline <script> tags in page markup are a related trap. They run once, on the first load, and not on each navigation, because the router swaps <body> without executing them. Code that should run per navigation belongs in an island; the router explains why.

Reach for an island, not a bare dynamic import

If you find yourself hand-rolling import() plus mount to lazy-load a component, use an island instead.

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

wake: 'idle' and wake: 'visible' already do exactly that, with the SSR HTML in place first and a signed boundary around it.

Choose the boundary before you build

A subtree that needs interactivity is an island, and one that doesn’t is a lake or plain page HTML. Wrapping static content in an island “to be safe” only ships JS you didn’t need, so 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 behaviours differ in dev, and both can hide defects until you ship:

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

Something that works in dev can therefore behave differently in production, so check data flows against a production build before you ship.