Regions
Held regions
An island that the server selects, and that you render like data. The server selects the component. The client paints it.
On this page
- Deferred (held) regions
- Where the schedule comes from
- Mountable .ts bindings
- Try it
- The region owns the wait
- Don’t await it yourself
- Crossing shapes: the wire law
- What await does
- Recipe: a lazy top-level return
- Composition: the recomposer
- Live regions
- Single-flight mutations
- A lake that can refresh
- Warm a hole early: preload(region)
- Do / don’t
A held region is a component that the server selects at runtime and the client renders. The props are type-checked and go over the wire. It is the ogygia version of RSC: the server decides the UI, and the client only paints it.
When to use
Use a held region when the selection of the component belongs to the server. Examples: search results that are a card or an empty-state, a feed with mixed item types, a dashboard tile that the backend decides. A server island fills a fixed hole with request-specific HTML. A held region lets the server select the component that fills it. The client never imports the options.
<script>
import { region, Region } from 'ogygia';
import Card from './Card.svelte';
</script>
<!-- inline: rendered in this SSR pass -->
<Region of={region(Card, { id })} />Deferred (held) regions
Mark the import, and the component becomes a signed capability. The server makes the capability in a load or a remote function. The client fetches it, swaps the HTML in, and hydrates. The client never imports the component. There are two markers:
with { wake: 'load' }bakes an interactive schedule.region()wakes it on that timing.with { region: 'raw' }bakes no schedule. The result is HTML only, unless theregion()call sets a schedule.
// server picks the component
import PackageCard from './PackageCard.svelte' with { wake: 'load' };
import EmptyResult from './EmptyResult.svelte' with { region: 'raw' };
import { query } from '$app/server';
import { region } from 'ogygia';
export const search = query(v.string(), async (raw) => {
const hit = DB[raw.toLowerCase()];
// PackageCard bakes wake:'load' → interactive; EmptyResult is HTML only.
return hit ? region(PackageCard, hit) : region(EmptyResult, { query: raw });
});<!-- the client renders whatever it receives -->
<script>
import { Region } from 'ogygia';
import { search } from './search.remote';
let result = $state(null);
</script>
<button onclick={async () => (result = await search(q))}>Search</button>
{#if result}<Region of={result} />{/if}Where the schedule comes from
The wake schedule of a held region is baked at the import with a wake: mark. The vocabulary is the same as for a placed island:
| Mark | Meaning |
|---|---|
wake: 'load' | interactive, hydrate on load |
wake: 'idle' | interactive, hydrate when idle |
wake: 'visible' | interactive, hydrate when scrolled into view |
wake: '(min-width: 60rem)' | interactive, hydrate when the media query matches |
region: 'raw' | HTML only, no baked schedule |
A held region has no render mark of its own. You decide when to fetch through the moment you render <Region>. The baked wake: mark, or the schedule at the region() call, decides when it wakes.
region: 'raw' is the escape hatch. It bakes nothing. Thus the region() call sets the schedule with a (data) => options function. This is applicable to a registry of raw components. Each component then decides its timing from its own data:
region(block, data, (d) => ({ wake: d.interactive ? 'load' : undefined }));Mountable .ts bindings
A .ts binding with with { wake: … } is a real component. It is not only a value for region(). Thus a third-party renderer can place it directly and get the <ogygia-region> shell. An example is a page builder, such as Builder.io, that renders a registered component with <svelte:component this={component}/>. Its JS loads only when two conditions are true: the component is placed, and its wake schedule fires. The binding also stays crossable over the wire through region(). The region metadata rides on the binding. One shape does both jobs:
import Widget from './Widget.svelte' with { wake: 'visible' };
export const registry = [
{ name: 'widget', component: Widget }, // Builder places it via <svelte:component>; wakes on scroll
];For a named or barrel import, which with { … } cannot reach, use the asRegion macro. It makes the same mountable binding:
import { Header } from '@company/ui';
const HeaderRegion = import.meta.og.asRegion(Header, { wake: 'visible' });with { region: 'raw' } stays a bare descriptor. It renders only through region(), not by direct placement.
Try it
The demo below is a search box. On submit, it calls a remote. The remote finds the query and selects a component on the server: an interactive PackageCard for a hit, a static EmptyResult for a miss. It signs the result and returns it. This page never imports the result components. Search svelte, kit, vite, or ogygia. All other queries return the static card.
Search to fetch a component from the server.
The star button of the card shows that it hydrated. The “no match” result ships as pure HTML. Look at the page source: there is no JS chunk for it.
The region owns the wait
One rule: the loading UI of a region lives on the region. A held-region fetch has two async phases, and they end at different times. First, the request: your remote call resolves. Second, the paint: the HTML lands, and its stylesheet loads. Only the region sees both phases. A loading flag, an {#await}, or a boundary next to the call sees only the first phase. Thus it says “done” before the card is on screen.
So do not track the wait at all. of accepts the promise directly from the remote call. Then the region owns the full wait. Its placeholder snippet is the one loading indicator, from start to end:
<button type="submit">Search</button> <!-- no loading state to reconcile -->
{#if query}
<Region of={search(query)}>
{#snippet placeholder()}<p class="hint">Searching…</p>{/snippet}
</Region>
{:else}
<p class="hint">Search to fetch a component from the server.</p>
{/if}The timeline on a slow link, from submit:
| Time | Phase | What’s on screen |
|---|---|---|
0 ms | request in flight | your Searching… placeholder |
| request resolves | HTML here; CSS <link> hoisted to <head>, sheet downloading | still the placeholder |
CSS load | region paints | the styled card, in one step |
There is no blank gap: the placeholder holds the space until the paint. There is no unstyled flash: the paint waits for the sheet. Nothing outside the region says when the region is ready. Thus nothing can disagree. ogygia limits the stylesheet wait to a few seconds. A broken sheet then paints, and does not stop the page.
A new search gives of a new promise. The region keeps the previous card on screen until the next result has its styles. Then it morphs in place. There is no placeholder flash between results.
Why a held region has this window at all: it carries its own CSS. The page never imported PackageCard. Thus its styles are on no page stylesheet. They travel with the region. A held region rendered in the server pass (a blocks page, SDUI) has no window: its links go into <head> during SSR, and it arrives with its styles. No placeholder is involved.
Why `placeholder`, not `ogygiaFallback`
ogygiaFallback is the reserved slot for a render: 'deferred' server island (a fixed hole). A held region is a value that you place. Thus its not-ready UI is a snippet on its <Region>. That is plain Svelte.
Don’t await it yourself
With experimental.async on, you can write of={await search(query)} inside a <svelte:boundary> and let its pending snippet control the wait. Do not do this. The boundary stops its wait when the fetch resolves. But the region still has a paint to do: swap the HTML, load its stylesheet. You then own a wait that you cannot fully see. Your “done” signal fires early.
The runtime still protects you. A region never paints without its styles. It holds its previous content, or its placeholder, or empty space, until the sheet is in. Thus the failure mode is a hold with nothing better to show, not a flash. But the correct fix is to not make the gap. Give the region the promise. Put the loading UI where the lifecycle lives. Boundaries, {#await}, and loading flags stay good for your UI around the region. Region readiness is not theirs to report.
Crossing shapes: the wire law
All examples above send one region. The general rule for many:
A region is the only unit of code that crosses. Everything else is data. Any data shape crosses if its leaves are regions.
Arrays, records, nesting: the transport does not care. It walks the value. It makes one signed capability for each region leaf that it finds, in each position:
// a mixed list — each leaf its own ticket
return hits.map((h) => (h.sponsored ? region(Ad, h) : region(Card, h)));
// named slots — mix eager and lazy freely
return { header: region(Head, u), main: await region(Chart, o), rail: region(Rail, u) };
// a region as a PROP of another region — the leaf decides where it renders
return await region(FeedCard, { author, media: region(VideoEmbed, { src }) });What await does
await region(…) renders the component on the server. It bakes that HTML into the ticket. Thus the region paints when the response lands, with no extra request. A bare return region(…) from an async remote does the same. The language awaits a thenable at return. Thus all values returned from a remote cross with their HTML baked.
Leaves nested inside a container are the exception. Nothing awaits them. Thus they cross as a plain address, and the client fetches each hole on mount. That is not a knob you turn. It follows from where the region sits. Timing is never the job of this axis. Timing is wake.
Recipe: a lazy top-level return
Sometimes one returned region must stay a plain address. Then a CDN can cache its endpoint across visitors, and no per-request HTML is baked into the response. There is no library helper for this, by intent. It is one line that you own. A region is an object, and its eager then is non-enumerable. Thus a spread drops it:
const lazy = <R>(r: R): R => ({ ...r }); // strips the auto-await; keep it in your codebase
export const card = query(v.string(), async (id) => lazy(region(Card, { id })));
// now `card()` ships a reference; the client fetches /🏝️?… which a CDN can cache with a maxAge presetUse this rarely. For a hole shared across visitors, a placed server island is usually the cleaner tool. It is endpoint-fetched and cacheable, and it does not touch the eager/lazy seam.
Composition: the recomposer
Sometimes the shape needs interpretation: a tree, a feed, a layout. Then ship a normal marked component. It takes the shape as props and changes it back into markup:
import FeedList from '$lib/FeedList.svelte' with { region: 'raw' };
return await region(FeedList, { items: hits.map((h) => region(cardFor(h), h)) });FeedList renders {#each items as it}<Region of={it} />{/each} plus its chrome. With await, the full composition renders in one server pass: one HTML payload with the stylesheet of each leaf. Nested interactive leaves are self-describing islands. They wake on their own schedules after they land. This operates recursively (trees with children). Thus a full blocks-style page can cross as one value.
Live regions
query.live can send a held region again and again over time. yield a held region on each tick. Its rendered, signed HTML rides the channel you already have. There is no per-tick fetch:
export const dashboard = query.live(v.string(), async function* (id) {
for await (const stats of feed(id)) yield region(StatCard, { stats });
});<Region of={dashboard(id).current} /> swaps the first tick in. After that, a raw (HTML-only, region: 'raw') held region morphs in place. Focus and typed text stay. An interactive one is kept alive: its state stays, and it does not hydrate again. One slot always shows the latest tick.
Single-flight mutations
A command that returns a held region sends the re-rendered HTML back in its own response. A mounted <Region> at that address updates from it. There is no follow-up fetch. One round trip both mutates and repaints:
export const bump = command(v.string(), async (id) => {
await db.increment(id);
return region(Counter, await db.get(id)); // the fresh region rides the command's response
});Call bump(id) from an island. The on-page region at that address repaints from the response of the command. There is no extra GET to the endpoint.
A lake that can refresh
A site header is often a lake: static markup, with islands and holes inside that wake on their own. When something the server renders from changes without a navigation, such as a saved language or a sign-in, render the header as a held region from a query, and refresh that query from a command:
// header-region.remote.ts: its own module (see the caveats)
import { command, getRequestEvent, query } from '$app/server';
import { region } from 'ogygia';
import Header from './Header.svelte'; // a plain import, no wake: HTML only
export const headerRegion = query(async () => region(Header, {}));
export const saveLocale = command(v.string(), async (locale) => {
getRequestEvent().cookies.set('locale', locale, { path: '/' });
await headerRegion().refresh(); // the refreshed header rides this response
});<Region of={headerRegion()} />What happens on the refresh:
- The header renders for the page it is mounted on. In the command’s request there is no page render. The region’s components still read the calling page’s
page.url,page.route,page.paramsandpage.data. ogygia looks that page up through Kit’s own data request, with the visitor’s cookies, after the command’s change. A hole fetched on its own gets the same. - It morphs in place. The header element stays, and so does an island inside it, with its state. An island reading
page.datashows the new values: the answer carries the keys its islands read, and they join the page’s data before anything wakes. - A hole refetches only when its answer can change. A hole’s address names its call and the page data its tree reads. When the locale changed, the hole fetches its new answer, and its current one stays on screen until then. When nothing changed, it keeps what it has.
Caveats:
- Put the region’s remote in its own module. The header’s own tree may import remotes, and the remote module imports the header. A component inside the header that needs to trigger the refresh can’t import the command: dispatch a DOM event and call the command where the
<Region>is mounted. - On a Kit-hydrated (
csr = true) page, Kit ownspage.data. Refresh the page’s data first (await invalidateAll()), then refresh the region, so the islands inside read the same values the header was rendered with. In dev, ogygia warns when they differ. - Only server loads count. A key a universal load (
+page.ts,+layout.ts) returns exists only inside a full page render. In dev, a region reading such a key names it. localsa load sets are not part of the page. The command’s request never runs the layout’s load. Set what the header reads fromlocalsin the remote function too.
Warm a hole early: preload(region)
preload(region) warms the HTML of one hole before its schedule. Thus the hole can paint early, while its own wake stays lazy. Import it from ogygia. It takes a region value and nothing else.
import { preload } from 'ogygia';
preload(region(Recommendations, { userId }));Do / don’t
- Do use a held region when the server owns the which-component decision. Use a server island when the component is fixed.
- Do select a raw held region (
with { region: 'raw' }) for result UI that never needs interaction. It ships zero JS. - Don’t import the result components on the client. The server sends them. The client only renders
<Region>.