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.
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):
// 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:
// 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:
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:
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.
