@sigx/resume
v1.0.1
Published
Resumability for SignalX SSR — QRL event handlers, zero-JS pages, upgrade-on-write hydration
Maintainers
Readme
@sigx/resume
Resumability for SignalX SSR — the second first-party strategy
pack riding @sigx/server-renderer's public plugin API.
Server pages render fully; the browser ships only a tiny delegation loader.
Event handlers are extracted at build time by sigxResume()
(@sigx/vite/resume) into lazily-imported QRL chunks that run against a
resumed scope of named signals — component setup never re-runs on load, and
the component chunk itself loads only when a handler writes state
(upgrade-on-write). It peers on sigx and @sigx/server-renderer
(a caret on the published version, ^1.0.0 from 1.0) — the app installs those once and owns the copy (docs/rfc-1.0.md
§3); @sigx/serialize is its only dependency:
- 0 JS on load — the page's only script is the generated loader entry.
- First interaction — the handler chunk (runtime-free, usually <1 kB)
loads and runs with
$scope.signals.<name>rebuilt from serialized state. The triggering event is replayed. - State changes — only then does the component chunk load and that one boundary hydrate; buffered writes replay through the live signals.
Server
The pack installs on the app — app.use(...) is the one install shape
(#413). The natural home is the entry-server's per-request app factory; the
manifest comes from virtual:sigx-manifests (inlined by the SSR build,
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 }));
}Any render method that receives the App picks the pack up from there —
createSSR().render(app), createRequestHandler({ app }),
createFetchHandler({ app }).
With a manifest, the document also gets <link rel="modulepreload"> for the
handler chunk of every resume boundary the request actually rendered
(ResumeManifest.handlers, #410) — 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; the
component (upgrade) chunk is never warmed — upgrade-on-write is lazy on
purpose. Dev renders manifest-less, so it preloads nothing there.
Components stamped by the transform (__resumeId) become boundaries with
hydrate: 'never' — core schedules nothing; the pack's delegation owns all
waking. Fully-extracted components resume through their QRL attributes;
components whose handlers could not all be extracted carry data-sigx-wake:*
attributes instead, and the first interaction fully hydrates them (no
replay). A component used with a client:* directive belongs to
@sigx/ssr-islands — register islandsPlugin() first when combining the
packs.
Single-flight boundary refresh (rfc-server §6.3)
createBoundaryRefresh builds the server half of single-flight refresh: a
mutation server function names boundaries to refresh, and the endpoint hands
their client descriptors here to be re-rendered — fresh HTML plus fresh
tracking-signal state in one response, so a never-hydrated boundary can
update without ever loading its chunk.
import { createBoundaryRefresh } from '@sigx/resume/server';
const renderBoundaries = createBoundaryRefresh({
plugins: [resumePlugin({ manifest })], // or omit and let `app` carry them
components: { Tracker, Cart } // registry key → server component
});
// handleServerFnRequest(request, { fns, renderBoundaries }) (wire phase of #313)refreshComponents is wired in two places, and both must agree. The
prod entry (entry.cloudflare.ts / entry.node.ts / …) builds
createBoundaryRefresh({ components }) for handleServerFnRequest, and the
dev half is the module sigxServer({ renderBoundaries: '/src/dev-refresh.ts' })
points at (examples/resume/src/dev-refresh.ts). Both take the same
export-name-keyed registry (examples/resume/src/entry-server.tsx exports
it once as refreshComponents for that reason). A component missing from
the registry is not an error: its descriptor is declined and the boundary
converges through $cache invalidation — one round trip slower, and the
chunk loads after all. Missing it in only one of the two places is the
classic "works in dev, not in prod" (or the reverse).
The plugins: form never sees app-level DI (rfc-1.0 §4.3). It builds
its re-render from the listed plugins only — serverPlugin({ types }),
provideTypeHandlers, anything app.use installed on the page's app, is
invisible to it, so a boundary whose props or state need those handlers
re-renders without them. An app that installs any of those passes the 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 callback receives whatever the endpoint passes through — its
ServerFnContext when riding handleServerFnRequest({ renderBoundaries }) —
for a factory that needs the request.)
plugins: stays the documented default for the zero-JS examples because
those pages have no app to hand.
The registry is explicit — same posture as the server-fn registry, never
ambient. Descriptors the re-render cannot honor (unknown key, a snapshot the
render can't reproduce, a component failure) are omitted, never errors: the
mutation already succeeded, and declined boundaries converge through
$cache invalidation. Boundaries whose usage-site props don't serialize
(children/slots/render props) are stamped refreshable: false at initial
SSR and decline the same way.
"Converges through $cache invalidation" needs a cache client.
cachePlugin() installs on an App (app.use), and an app-less resumable
page — examples/resume, examples/storefront, any page whose only script
is the loader entry — has none (rfc-1.0 §4.4). There, a declined or dropped
refresh leaves the boundary exactly as rendered until the next navigation;
nothing converges later. Wire refreshComponents in both places and keep
the usage-site props serializable so the refresh is honoured in the first
place. The app-less cache surface is tracked in #415.
The client half is automatic: @sigx/resume/client stamps the
__SIGX_SERVERFN_BOUNDARIES__ seam when it loads, so any
invalidates-declaring mutation sends the page's boundary inventory
(each boundary's recorded useData deps included — the endpoint admits
on deps ∩ invalidates) and
applies the fresh entries — a never-hydrated boundary is DOM-swapped
(delegation re-wires itself off the fresh attributes; its chunk never
loads), an upgraded one gets live-signal writes. An in-flight upgrade,
buffered writes, or a focused text field inside the boundary all win over
a refresh — drops converge through cache invalidation. In dev, pass
sigxServer({ renderBoundaries: '/src/dev-refresh.ts' }) a module
exporting the same createBoundaryRefresh result (see
examples/resume/src/dev-refresh.ts).
Client
Two postures, and picking the wrong one is silent — 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, and on the client import only the generated loader entry:
// 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 is the point: hydrate() walks the root as
usual and skips resume boundaries on the way past — they are recorded
hydrate: 'never' by the server plugin — while everything around them
hydrates normally. The loader wakes a boundary on first interaction.
You may also app.use(resumePlugin()) on the client (it is harmless — the
client half only registers provides), but it is not required.
Before #483 this was a trap.
resumePlugin()'s install declaredboundaries: 'explicit'unconditionally, which switcheshydrate()to the no-root-walk path. Combined withhydrate: 'never'records, that meant an app installing the plugin on the client hydrated nothing at all — a dead shell, with no error and no warning. The plugin no longer declares a mode unless you ask for one.
App-less resumable pages
A page whose entire bootstrap is the generated loader entry — no root app, no hydration walk, ~1 KB of JS. Nothing to configure; just don't create an app. If you do have an app and deliberately want no root walk (the islands posture), say so explicitly:
app.use(resumePlugin({ boundaries: 'explicit' }));Writing resumable components
Ordinary sigx components in resume modules (*.resume.tsx or under a
resume/ directory — configurable on the Vite plugin):
export const Counter = component<{ label: string }>((ctx) => {
const count = ctx.signal(0);
return () => (
<button onClick={() => count.value++}>
{ctx.props.label}: {count.value}
</button>
);
});No QRL API, no registration — the transform derives everything. Named =
transferred: signals declared as const x = ctx.signal(…) are keyed by their
declaration name and serialized; anything else stays local. A handler is
resumable when its captures can be expressed through the resumed scope (named
signals, ctx.props reads, imports, globals); anything else (loop variables,
setup helpers, ctx.emit, a spread of unanalyzable props on a host element,
an onUpdate:* model-binding callback, a generator handler, …) makes the
whole component fall back to wake-on-interaction — first interaction
hydrates it, with a build-time warning naming the capture. Handlers are only
ever found on host elements: an on* prop passed to a child component
is a build error (below).
preventDefault has one shape: an unconditional top-level statement of the
handler body, e.preventDefault() on the event parameter itself. The loader
cancels the native default synchronously from a data-sigx-pd stamp — before
any JavaScript loads, and for the page's lifetime — so a guarded or indirect
call (inside if / ?: / && / try, a nested function, after a
return, throw, await or yield; an alias, a destructured
preventDefault, or the event handed to a helper) can neither be stamped
nor left unstamped on an element with a native default (<form> submit,
<a href> click, a submit button, a checkbox, keydown, …): the component
falls back to wake-on-interaction with a reason. On an element with no
native default it extracts without a stamp. An imported handler on such an
element falls back the same way — the call cannot be seen across modules.
$scope.props is a snapshot, and a public one. It is 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 — passed through the app's type handlers; the same object the
upgrade mounts with. A handler that calls a props member
(ctx.props.onSelect(id)), reads an on* prop, or reads one of the
stripped keys (directly or by destructuring) falls back to
wake-on-interaction with a reason: the value cannot exist in a resumed
scope. And because the table ships in the HTML, 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 (rfc-1.0 §4.5) — never a warning that leaves
the component silently broken in prod; runtime ones are __DEV__ warnings,
because a throw in the browser would take the page down.
Build errors (sigxResume()):
- Named exports only. A component that leaves the module as
export default—export default component(...),export default Counter,export { Counter as default }— is a build error:resume components must be named exports. A default export has no name to key the registry and manifest, so it used to be silently non-resumable. A default export that is not a component (a config object, a helper) is fine; so is a named export that is also aliased to default. - Export names are app-wide keys. The export name is the
__resumeId— the registry key on the client and the manifest key on the server — so two resume modules exporting the same name is a build error naming both files. Rename one. (Names in non-resume files don't count.) - The setup context is one identifier. A component with handler sites
must take its context as a plain parameter —
component((ctx) => …). Destructuring it (({ signal, props }) => …) or declaring none is a build error:signal(…)declarations can only be keyed by name throughctx.signal, handled elements carrydata-sigx-b={ctx.$sigxB}, and without the name the transform cannot tell a setup capture from a global. - No slots. A component with handler sites that reads
ctx.slots(directly, or by destructuringctx) is a build error. Both modes end in the data-driven upgrade, which mounts the component from its serialized record — no children, no slots — so on first interaction the slotted content would be orphaned and any fallback rendered over it. Move the slot host out of the resume module, or take the content as serializable props. (A slot consumer with no handler site never upgrades and is left alone.) - Handlers live on host elements.
<Child onClick={…} />is a build error: delegation only sees host-element attributes, so nothing in the child's DOM could run the handler or wake the boundary, and a function prop never reaches the client through the boundary table. Handle the event on a host element inside the child, or render the element here. $scopeand$elare reserved. Inside a resumable handler,$scopeis the resumed scope (handlers are re-emitted as($scope, …) => …);$elis reserved for the runtime and not bound — the delegated element isevent.currentTarget(see "What a replayed event looks like" below). A handler that binds either — as a parameter, a local, a named signal or a destructured prop — or reads it as a free reference is a build error.obj.$scopeand{ $scope: 1 }(member and key positions) are not references and stay allowed.
Dev trace (@sigx/resume/client, __DEV__ only). The console shows the
ladder as it happens: boundary N (X): replaying "<symbol>" (click),
first write to "count" — upgrading (loading the component chunk),
upgraded — real listeners now own the element, and
woke (hydrate mode) — the triggering event is not replayed. Stripped from
the prod dist.
Dev warnings (runtime, @sigx/resume/client):
- 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. A fragment root
does not match that element, and a root with no element at all (text
only, or a conditional that rendered nothing) leaves the boundary not
upgraded: the QRL ran and its write is buffered, the scope resets so the
next write retries — against the same DOM, with the same result — 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 and replayed once the live
signal exists. If the component no longer declares a named signal of that
name (renamed since the page was rendered — a deploy in between), the
buffered value is dropped; dev warns
Buffered write to "x" has no live signal after upgrade. - Lossy usage sites. Dev warns
Boundary N (X) was rendered with usage-site props the snapshot cannot carrywhen an upgrade mounts a boundary whose usage site passed children, slots or render props — the transform refusesctx.slotsin a component with handlers, and this is the net for what it cannot see (a helper reading slots through a passed ctx, a usage site handing children to a component that ignores them). - Wake swallows the triggering event. A component in wake-on-interaction
mode (any ineligible handler) 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 (see
DealOfTheDayinexamples/storefrontfor the pattern), or design the first interaction to be harmless to lose.
What a replayed event looks like. The loader replays the same native
Event object, after native dispatch has ended, and calls the handler with
exactly what live dispatch would have — the event, nothing else (a second
declared parameter is undefined on replay just as it is after upgrade):
event.targetis intact.event.currentTargetis the delegated element for the duration of each handler's synchronous run in the synthetic bubble —e.currentTarget.valuereads as it would from a live listener — andnullagain the moment the handler returns, as after native dispatch (an async continuation reading it late sees what it would live).event.defaultPreventedreflects thedata-sigx-pdstamp: the loader cancels the default synchronously during native dispatch when the handler body callspreventDefault(), and the call inside the replayed handler is itself a no-op (too late).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 — a live listener on an ancestor would never have seen it. event.eventPhaseis0andevent.composedPath()is[]— the post-dispatch values. Read the element fromcurrentTarget.
Server-side, refreshComponents must be wired in both the dev and the prod
refresh entry — see "Single-flight boundary refresh" above.
Verification
The full ladder is verified in a real browser: examples/resume/smoke.mjs
asserts (via JS coverage — execution, not fetches) that only the loader
executes on load, the first click replays through its QRL and upgrades on
write (exactly once — the #266 double-fire regression), read-only handlers
never execute their component chunk, the single-flight refresh patches
fresh HTML with no component chunk, and wake-on-interaction hydrates
without replay. pnpm smoke:resume runs it in both modes (CI job
resume-smoke): node smoke.mjs against the prod build, and
node smoke.mjs --dev against the dev server.
Dev is the same ladder. The transform runs for the dev SSR render, so
the served HTML carries the same QRL/wake/pd/b attributes; the dev server
serves the handlers modules as virtuals and the registry lazily imports
the component modules; the loader entry is what boots; and the dev trace
above narrates every replay, upgrade and wake in the console. What is
prod-only is bundle shape — chunk files, the manifest, modulepreload
hints — never behaviour. Editing a resume module's handler in dev reloads
the page (handler symbols are content-hashed; the rendered attributes and
the evaluated registry hold the old ones); a markup-only edit keeps
in-place HMR. The server half is WinterCG-clean: after pnpm build,
pnpm test:edge renders a resumable boundary from the prod dist with
node: imports forbidden.
Platform findings from building this pack: docs/resume-stress-test-findings.md.
Credits
The resumability model — serialized handler references, global event
delegation with replay, and no client re-execution of component setup — was
pioneered by Qwik. This pack adapts it to sigx signals
and the @sigx/server-renderer plugin platform.
