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 makes the whole component fall back to wake-on-interaction — its first interaction hydrates it — with a build-time warning naming the reason. Handlers are only ever found on host elements. The full set of rules is the contract below.
preventDefault has one shape: an unconditional top-level e.preventDefault() on the event parameter itself. The loader cancels the native default from a data-sigx-pd stamp before any JavaScript loads, so it can only honour a call it can see statically.
$scope.props — what a handler reads as ctx.props — is a snapshot, and a public one: the usage-site props as serialized into the boundary table, minus children, slots, $models, key, ref, every function, symbol and undefined, and every on* key. The table ships in the HTML, so every serialized prop of every resumable component is readable by anyone who views source — pass a resumable component only what the page may show.
The contract
These are the rules the transform and the runtime rely on. Transform-time violations are build errors — never a warning that leaves a component silently broken in production. Runtime violations are development warnings, because a throw in the browser would take the page down.
Build errors
| In a resume module | The error | Why |
|---|---|---|
A component that leaves the module only as export default — export default component(…), export default Counter, export { Counter as default } | resume components must be named exports (with file:line:col) | The export name keys the registry and the manifest; a default export has none. A default export that is not a component, or a named export also aliased to default, is fine. |
| The same component name exported from two resume modules | duplicate resume component name "X": a.tsx and b.tsx both export it | Export names are app-wide keys. Rename one. Names in non-resume files don't count. |
A component with handler sites whose setup context is destructured (({ signal, props }) => …) or absent | resume components must take the setup context as a single identifier parameter | Signals are keyed by name through ctx.signal, and without the name the transform cannot tell a setup capture from a global. Write component((ctx) => …). |
A component with handler sites that reads ctx.slots (directly or by destructuring ctx) | resumable components cannot consume slots | The upgrade mounts the component from its serialized record — no children, no slots — so slotted content would be orphaned. Take the content as serializable props, or move the slot host out of the resume module. A slot consumer with no handler site never upgrades and is left alone. |
An on* handler passed to a child component tag — <Child onClick={…} /> | names the tag | Delegation only sees host-element attributes, and a function prop never reaches the client. Handle the event on a host element inside the child, or render the element here. |
A spread or onUpdate:* callback on a component that has named signals but no host-element handler | build error | With no handler to wake on, the component would be just as dead in hydrate mode. |
A handler that binds or references $scope / $el — as a parameter, local, named signal or destructured prop | names the handler | Both names are reserved. obj.$scope and { $scope: 1 } are not references and stay allowed. |
Falling back to wake-on-interaction
These shapes are legal but cannot run from a resumed scope, so the whole component falls back to wake-on-interaction, with a build-time warning naming the reason:
- a capture the resumed scope cannot express — a loop variable, a setup helper,
ctx.emit; - spread props on a host element (a handler-free object literal, inline or a setup-scope
const, is exempt); - a namespaced
onUpdate:*callback on a host element — it is not a DOM event; - a generator handler;
- a guarded or indirect
preventDefault()— insideif/?:/&&/try, after areturnorawait, through an alias, or with the event handed to a helper — or an imported handler, on an element with a native default (<form>submit, an<a href>click, a submit button, a checkbox,keydown,wheel,touch*, …). On an element with no native default, a guarded call extracts without a stamp; - a handler that reads what the props snapshot never carries — calling a props member (
ctx.props.onSelect(id)), reading anon*prop, or readingchildren,slots,ref,keyor$models.
A woken component fully hydrates on its first interaction and does not replay that event — its listeners only exist after hydration. If the first click must count, keep the handler resumable, or make the first interaction harmless to lose.
What a replayed event looks like
The loader replays the same native Event, after native dispatch has ended, and calls the handler with exactly what live dispatch would have — the event, nothing else:
event.targetis intact.event.currentTargetis the delegated element for the duration of each handler's synchronous run, soe.currentTarget.valuereads as it would from a live listener, andnullagain once the handler returns.event.defaultPreventedreflects thedata-sigx-pdstamp — the loader cancelled the default during native dispatch; thepreventDefault()call inside the replayed handler is a no-op.event.stopPropagation()ends the synthetic bubble only; native propagation already happened.- A non-bubbling event (
focus,blur,mouseenter,scroll, …) replays only on its target's carrier. event.eventPhaseis0andevent.composedPath()is[]. Read the element fromcurrentTarget;$elis reserved but not bound.
Development warnings
- Single-element root. The upgrade hydrates the element immediately before the boundary's trailing marker as the component's root, so a resumable component must render exactly one root element. Without one, the boundary is not upgraded and the page never updates; dev warns
Cannot upgrade boundary N: no element before its marker. - Renaming a signal drops buffered writes. Writes made before the component chunk loads are buffered by name. If the component no longer declares a signal of that name — renamed by a deploy since the page rendered — the value is dropped; dev warns
Buffered write to "x" has no live signal after upgrade. - Lossy usage sites. An upgrade of a boundary whose usage site passed children, slots or render props warns that the snapshot cannot carry them.
In development the console also narrates the ladder as it happens — [sigx resume] lines for each replay, the first write that triggers an upgrade, the upgrade itself, and a wake. The trace is stripped from production builds.
In development
Dev runs the same ladder as production: the transform runs for the dev SSR render, so the served HTML carries the same attributes, and only bundle shape — chunk files, the manifest, preload hints — is production-only. Editing a handler in a resume module reloads the page, because handler symbols are content-hashed and the rendered page holds the old ones; a markup-only edit keeps in-place HMR.
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.
With a manifest, the document also gets a <link rel="modulepreload"> for the handler chunk of every resume boundary the request actually rendered. The bytes arrive off the critical path; execution still waits for the first interaction, and the loader stays the page's only script. A page without a resume boundary emits nothing, hydrate-mode components and manifest-less dev renders emit nothing, and the component (upgrade) chunk is never preloaded — upgrade-on-write stays lazy. Under a CSP, the preload links carry the request's nonce.
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, { functions: serverFns, 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.
Wire refreshComponents in both places
The production entry (entry.cloudflare.ts, entry.node.ts, …) builds createBoundaryRefresh({ components }) for its request handler, and development uses the module sigxServer({ renderBoundaries: '/src/dev-refresh.ts' }) points at. Both take the same export-name-keyed registry, so export it once — as refreshComponents from your server entry — and import it in both. A component missing from the registry is not an error: its boundary is declined and converges through cache invalidation, one round trip slower. Missing it in only one of the two places is the classic "works in dev, not in prod", or the reverse.
plugins: never sees app-level DI
The plugins: form builds its re-render from the listed plugins only. Anything app.use installed on the page's app — serverPlugin({ types }), provideTypeHandlers, other provides — is invisible to it, so a boundary whose props or state need those type handlers re-renders without them. An app that installs any of those passes its app factory instead, and the app's plugins win:
const renderBoundaries = createBoundaryRefresh({
app: () => createApp(), // the entry-server's factory — plugins, type handlers and all
components: refreshComponents,
});
The factory receives whatever the endpoint passes through — its request context when it rides handleServerFnRequest({ renderBoundaries }) — for an app that needs the request. plugins: stays the right choice for an app-less resumable page, which has no app to hand.
Convergence needs a cache client
"Converges through cache invalidation" assumes a cache client on the page, and cachePlugin() installs on an App. An app-less resumable page — one whose only script is the loader — has none, so a declined or dropped refresh leaves the boundary exactly as rendered until the next navigation. There, wire refreshComponents in both places and keep usage-site props serializable, so the refresh is honoured in the first place.
