Skip to content

Regions

Timing & nesting

The schedules that decide when JS runs or HTML arrives, and the one rule for islands inside islands.

On this page

Two small systems cover much ground: when a region wakes, and what occurs when regions nest. Both are small by intent.

The schedules

One key, wake, carries the schedule for islands (wake) and for server islands (render: 'deferred'). Thus you learn the words one time:

ValueWhen it firesGood for
loadImmediately after the runtime startsUI above the fold that must be interactive now
idleOn requestIdleCallbackWidgets that can wait
visibleWhen scrolled into the viewportAll content below the fold
interactionOn the first pointer, key, or focus inside it (islands only)UI that most visitors never touch
'(media query)'When the CSS media query matchesUI for mobile only or desktop only

On an island (render defaults to static), wake says when the JS runs. On a server island (render: 'deferred'), wake says when the HTML is fetched. The words are the same for both. interaction is for islands only. A fetch on interaction would mean interaction with a fallback.

<script>
  import Now from '$lib/Now.svelte' with { wake: 'load' };
  import Later from '$lib/Later.svelte' with { wake: 'idle' };
  import Below from '$lib/Below.svelte' with { wake: 'visible' };
  import Phone from '$lib/Phone.svelte' with { wake: '(max-width: 600px)' };
</script>

Each of the four demos below is a real island with that schedule:

Live since —

Idle after …

--:--:--

In view · —

0px · no match

interaction: JS only for what people use

wake: 'interaction' is the laziest schedule. The island ships zero JS until a person uses it. A pointer press, a keystroke, or focus inside the region wakes it. The interaction that woke it is not lost:

  • The first click counts. ogygia captures it while the island wakes, and replays it when the island is live. A button, a checkbox, or a link activates exactly one time.
  • Typed text stays. Characters land in the field natively, because it is real HTML. After hydration, the typed value, the selection, and the focus are restored, and bind: syncs.
  • Hover warms it. When the pointer enters the region, the chunk prefetches. Thus, when the press lands, the wake is usually immediate. For an on-demand hole (render: 'deferred', wake: 'interaction') the hover fetches and swaps in one go; add prefetch: 'idle' to warm its HTML at page idle instead, so the first hover finds it already there (see prefetch in regions.presets).

The same holds for every other schedule until the island is live. A load, idle, visible or media island is server markup with no handlers until it wakes, so a press on it before then wakes it at once, and the click is replayed when it is live. A Ctrl/Cmd/Shift- or middle-click on a link opens it as the browser would, since a replay could not.

One caution: the replayed first event is not a trusted browser gesture. Gesture-gated APIs (window.open, clipboard, fullscreen) are blocked if called from it. A component that ignores untrusted clicks (if (!event.isTrusted) return, often written to skip its own synthetic clicks) ignores the replay too, so narrow such a guard. A component can find this condition and adapt:

<script>
  import { hydratedBy } from 'ogygia';

  // 'interaction' when a visitor's input woke it (its first click is a replay), else its schedule:
  // 'load' | 'idle' | 'visible' | a media query — or null during SSR.
  // Call during setup, like getContext.
  const woke = hydratedBy();
</script>

{#if woke === 'interaction'}
  <!-- first activation was a replay: render a real <a target="_blank"> instead of window.open -->
{/if}

Do not put gesture-gated actions behind interaction. All other UI (counters, carts, menus, forms, editors) operates correctly, and costs nothing until a person touches it.

Tuning visible

visible uses an IntersectionObserver. Make its trigger margin wider per import, or set a default for the full app:

import Chart from '$lib/Chart.svelte' with { wake: 'visible', margin: '200px' };
// default margin for every `visible` island
ogygia({ regions: { visible: { margin: '0px' } } });

How the bytes arrive

The wake decides when an island’s code downloads as well as when it runs. Nothing to configure:

  • No island code moves before your hero has arrived. Every wake except interaction waits until the page has painted and every image or preload you marked fetchpriority="high" has loaded (that is your LCP resource: ogygia does not guess it). The visitor’s first click or key ends the wait at once (the browser stops measuring LCP there), and so does a 2.5 s cap. A page with no marked image waits only for the paint. Mark your hero:

    <img src="/hero.jpg" fetchpriority="high" alt="…" />
    <!-- or, for a CSS background or <picture> hero -->
    <link rel="preload" as="image" href="/hero.jpg" fetchpriority="high" />
  • Downloads are queued by who is waiting for them. An island or hole on screen downloads at normal priority. One that is not on screen yet (an idle island, one screen ahead of a visible one, a hole’s prefetch) and the router’s page prefetch download at low priority, a few at a time, so they never crowd out what the visitor sees. Something that scrolls into view moves up the queue. A click never waits. A hole’s HTML on screen never waits either: it is the content itself.

  • When an island wakes, its whole graph downloads at once. The page carries each island’s chunk list as data (one small JSON script, every chunk named once). At the wake the runtime adds a <link rel="modulepreload"> for each chunk the island needs, beside the island’s own import(). The browser fetches them in parallel, instead of finding each import only after the last file parsed.

  • An island that never wakes downloads nothing. A visible island fetches when it intersects (regions.visible.margin is the lead time), an idle one in idle time, a media-query one when the query matches, an interaction one on the first pointer-over, focus or touch inside it (the hover warm-up, with the waking click replayed). The router’s hover prefetch warms the next page’s islands the same way.

On a csr = true page Kit wakes the islands, not the runtime, so there a load island keeps a fetchpriority="low" modulepreload hint at the end of the body.

Props and the page seed come after the content. Each island’s serialized props sit at the end of the body in one <script type="application/ogygia-props"> per distinct island, keyed by the region’s fingerprint, never next to the island. The page seed (page.data for islands that read $page) comes last, and only when an island on the page actually reads it. A deferred hole’s <link rel="preload" as="fetch"> stays in the head: it fetches content, not code.

Props do not copy what the seed already carries. When the seed ships, an island’s props are written relative to it: a plain object or array that is also a node of page.data, whether the page passed the very same object or a clone of it, becomes a short path into the seed instead of a second copy. The island still receives its own copy of the data when it wakes; nothing it does to its props reaches the seed or another island. A CMS page that hands twelve blocks their slices of one content tree ships the tree once and twelve paths.

CSS follows the same rule: the render decides. On a csr=false page the server pass links a stylesheet for each island that actually renders (<link rel="stylesheet" data-ogygia-region-css>, one per sheet per request) and nothing for the rest. A marked import the page never places, or the other blocks of a registry that rendered one, cost no stylesheet and no chunk hint. Kit’s own links cover the page’s static CSS (the page and layout components’ styles); island CSS is not in that graph on purpose, so a 300-block registry does not become 300 render-blocking sheets. Kit’s inlineStyleThreshold governs both channels: a region sheet under it ships as <style data-ogygia-region-css> in the head instead of a blocking <link> (a hole answer’s inlined sheet is hoisted and deduped the same way), so twenty small island sheets cost no requests before first paint.

Deferred content that is also interactive

A render: 'deferred' region is content only. It never ships JS. On it, wake is the fetch schedule. In the example below, the hole waits until it scrolls into view. Before that, it does not cost a request:

import Panel from '$lib/Panel.svelte' with { render: 'deferred', wake: 'visible' };

Sometimes a fetched hole also needs interactivity. Do not stack two schedules on one import. Nest a normal wake island inside the component of the server island. render decides how the HTML arrives. wake on the inner island decides when its JS runs. See Server islands → nesting an island.

Nesting: one rule

Walk up the tree. The closest marked parent decides.

NestingWhat happens
Island on the pageGets its own JS
Island inside an islandShares the JS of its parent: one interactive tree, not two
Lake inside an islandStays static HTML. The JS of the lake never ships
Island inside a lakeGets its own JS again
Server island inside an islandRenders inline as static HTML, like a lake (render: 'deferred' ignored). Its JS never ships, and an island inside it gets its own JS
Server island inside a live snippetStays deferred: its fallback ships, its HTML arrives on its schedule
page → island → lake → island → …

An island in an island shares one tree. Thus the schedule of the child is ignored. It hydrates with its parent. Dev warns when it drops an unnecessary strategy.

Do / don’t

  • Do set islands below the fold to visible. This performance gain has no cost.
  • Do use idle for UI that the user does not touch immediately.
  • Don’t try to make a render: 'deferred' region interactive alone. It ships no JS. Nest a wake island inside it.