Server/Packages/Resume/Usage
@sigx/resume · Stable

Using Resume#

Ordinary components in resume modules, one plugin install, and an optional boundary-refresh endpoint.

Resumable components#

Write ordinary SignalX components in a *.resume.tsx file (or under a configured resume/ directory) — no QRL API, no registration. The transform derives everything: named signals (const x = ctx.signal(…), keyed by declaration name) are serialized and rebuilt in the resumed scope; anything else stays local.

TSX
export const Counter = component<{ label: string }>((ctx) => {
    const count = ctx.signal(0);
    return () => (
        <button onClick={() => count.value++}>
            {ctx.props.label}: {count.value}
        </button>
    );
});

A handler is resumable when its captures resolve through the resumed scope — named signals, ctx.props reads, imports, globals. Anything else (loop variables, setup helpers, ctx.emit) makes the whole component fall back to wake-on-interaction, with a build-time warning naming the capture.

Install the plugin#

resumePlugin is an SSR pack — install it on the app in your per-request entry, passing the build's manifest (which is undefined under dev, where resume runs manifest-less):

TSX
// src/entry-server.tsx
import { defineApp } from 'sigx';
import { resumePlugin } from '@sigx/resume';
import { resumeManifest } from 'virtual:sigx-manifests';

export function createApp(url: string) {
    return defineApp(<App />).use(resumePlugin({ manifest: resumeManifest }));
}

The sigxResume() transform from @sigx/vite/resume produces the manifest and the delegation loader entry; the browser loads only that loader until an interaction upgrades a boundary.

The client half#

There are two postures, and picking the wrong one fails silently — so pick deliberately.

Coexisting with a hydrated app#

The common case: an app that has a root — a shell, a router, ordinary interactive components — and also wants some resumable components on the page. Install the plugin on the server app as above, and on the client import only the generated loader entry:

TSX
// src/entry-client.tsx
import { defineApp } from 'sigx';
import 'virtual:sigx-resume/entry';   // the delegation loader — that's it
import { App } from './App';

const app = defineApp(<App />);
app.hydrate(document.getElementById('app')!);

Nothing else is needed, and that's the point. hydrate() walks the root as usual and skips resume boundaries on the way past — the server plugin records them hydrate: 'never' — while everything around them hydrates normally. The loader wakes a boundary on first interaction.

Adding app.use(resumePlugin()) before hydrate() is harmless but unnecessary: on the client the plugin only registers the pack's DI provides, and none of the wake-on-interaction behaviour depends on it.

App-less resumable pages#

A page whose entire bootstrap is the loader entry: no root app and no hydration walk, so the browser downloads little more than the loader itself. There's nothing to configure — just don't create an app.

If you do have an app and deliberately want no root walk (the islands posture), ask for it:

TypeScript
app.use(resumePlugin({ boundaries: 'explicit' }));

That mode takes hydrate() down the no-root-walk path, so it schedules the boundary table and returns. Since every resume record is hydrate: 'never', combining it with a root app you expected to hydrate leaves a dead shell — which is why it's opt-in rather than the default.

Progressive enhancement and form: true#

A server function marked form: true gets its native action stamped by the resume extractor, so the stamping reaches only files matching sigxResume()'s include and components that extract in resume mode. A <form> anywhere else still submits through the server function as RPC, once its component is running — hydrated, or resumed on interaction — it just has no native no-JS fallback to fall back to before then.

The build warns when a form: true <form> will never be stamped, either because its file is outside include or because the component fell back to hydrate mode.

What makes a boundary unrefreshable#

A boundary is stamped refreshable: false when its props snapshot can't reproduce the render. Dropped event handlers are forgiven — they never shape server HTML — and the rule is an on* prop whose value is a function, whatever its spelling: onclick counts exactly as onClick does.

An on-prefixed data prop that the snapshot can't carry is a different thing, and correctly still counts as dropped state.

Single-flight boundary refresh#

To let a mutation update the server-rendered boundaries it invalidated, build a refresher with createBoundaryRefresh and hand it to your server-function request handler as its renderBoundaries option:

TypeScript
import { createBoundaryRefresh } from '@sigx/resume/server';

const renderBoundaries = createBoundaryRefresh({
    plugins: [resumePlugin({ manifest })],  // or omit and let `app` carry them
    components: { Tracker, Cart },          // registry key (__resumeId) → server component
});
// handleServerFnRequest(request, { fns, renderBoundaries })

components is required and explicit (never ambient) — the same posture as the server-function registry, since the key comes from the client. When a mutation server function declares invalidates, the endpoint admits a boundary whose recorded useData deps intersect it, re-renders each in an id-seeded isolated context, and returns fresh HTML + state in one response. The client half is automatic (@sigx/resume/client applies the fresh entries) — a never-hydrated boundary updates without loading its chunk.

Nothing throws outward: a boundary it can't honor (unknown key, unserializable props, a render throw) is silently omitted, and those reads converge through @sigx/cache invalidation instead.