Handshake module
Reference for the handshake module: full connection snapshot, derived capabilities, and lifecycle controls.
A statechart-driven view of the widget ↔ Grist relationship. Exposes the
full snapshot, derived capabilities, and lifecycle controls — useful when
the four-state status (booting / ready / unavailable / error) is
not enough (degraded networks, schema-write gating, optimistic retries…).
import {
GristHandshakeProvider,
useGristHandshake,
useGristCapabilities,
useGristHandshakeContext,
useGristHandshakeContextOptional,
type GristWidgetSnapshot,
type GristCapabilities,
} from "grist-widget-sdk/advanced"Everything in this module is independent of <GristWidgetProvider> /
useGrist(). You can mount it alongside, or use it standalone in a tiny
widget that just needs the snapshot.
When to use this vs the default surface
| Goal | Use |
|---|---|
| Show "Connecting…" → "Ready" UI, retry on error. | <GristBoundary> + useGristStatus(). |
| Branch on "can I write rows?" vs "can I render?". | useGristCapabilities(). |
| React to "link is stale, going offline soon". | useGristHandshake() → snapshot.link.state. |
| Show a "loading mappings" hint distinct from "ready". | snapshot.config.mappings.state. |
| Drive a custom retry/backoff overlay. | useGristHandshake() → reload() / restart(). |
| Telemetry on lifecycle transitions. | Subscribe to manager via <GristHandshakeProvider>. |
The default surface (useGrist() and friends) already runs the full
FSM under the hood since 0.2.0 — useGrist().status is a projection of
the same snapshot. These hooks just expose the fuller story.
<GristHandshakeProvider>
Mount this once if you want a single shared snapshot across many
descendants. Without it, every call to useGristHandshake() spawns its
own manager — fine for a leaf widget, wasteful when several siblings
need the same data.
import { GristHandshakeProvider, useGristHandshakeContext } from "grist-widget-sdk/advanced"
<GristHandshakeProvider options={{ requiredAccess: "read table" }}>
<App />
</GristHandshakeProvider>
function App() {
const { snapshot, capabilities, reload } = useGristHandshakeContext()
// …
return null
}Props
type GristHandshakeProviderProps = {
options?: UseGristHandshakeOptions
children: React.ReactNode
}
type UseGristHandshakeOptions = GristReadyOptions & {
detect?: DetectOptions
negotiate?: NegotiateOptions
mappings?: MappingsOptions
/** `false` disables the heartbeat entirely. */
heartbeat?: HeartbeatOptions | false
}Coexists with <GristWidgetProvider> — both providers can be mounted in
the same tree without interference. They share the page-level
ensureGristReady() singleton so grist.ready is invoked once per page.
useGristHandshake(options?)
Standalone hook (no provider needed). Spawns a manager and subscribes to
its snapshot via useSyncExternalStore.
const {
snapshot,
status,
error,
capabilities,
reload,
restart,
} = useGristHandshake({ requiredAccess: "read table" })Returns:
type UseGristHandshakeResult = {
snapshot: GristWidgetSnapshot
status: GristWidgetStatus // booting | ready | unavailable | error
error: string | null
capabilities: GristCapabilities
/** Soft retry: bumps generation, re-runs detect → negotiate. */
reload: () => void
/** Hard reload: triggers `window.location.reload()` in browsers. */
restart: () => void
}Inside a provider
const { snapshot, capabilities } = useGristHandshakeContext() // throws if no provider
const ctx = useGristHandshakeContextOptional() // returns null if no provideruseGristHandshakeContext() exposes the same shape as the standalone
hook plus the manager's recordRpcSuccess() / recordRpcFailure()
hooks (useful when integrating with a custom RPC layer outside the SDK's
slice hooks — see Heartbeat coalescence).
useGristCapabilities(options?)
Convenience hook that returns only the derived capabilities slice. Equivalent
to useGristHandshake(options).capabilities but stable across snapshot
changes that don't affect capabilities.
const { canRead, canRender, canWriteRecords, canWriteSchema } = useGristCapabilities()
if (!canRender) return <Skeleton />
return canWriteRecords ? <Editable /> : <ReadOnly />Prop
Type
hasUsableMappings, missingMappings, and emptyMultipleMappings mirror the
same mapping state useGrist().columnMappingStatus exposes — see
ValidateColumnMappingsResult.
Capabilities form a conjunctive chain — canWriteSchema ⇒ canWriteRecords ⇒ canRender ⇒ canRead. The chain is asserted by property tests in
tests/unit/handshake-properties.test.ts.
The snapshot
Prop
Type
Lifecycle
Prop
Type
Prop
Type
terminated is absorbing — no action can revive it without a reload().
Link
Prop
Type
state is GristLinkState: "connected" | "stale" | "lost" — there is no
"unknown" link state; before the first successful RPC the link simply
hasn't been observed yet (lastSuccessMs: null).
The heartbeat moves the link through connected → stale → lost based on
missed probes (see Heartbeat below). lost
escalates to global status === "error".
Config / mappings
Prop
Type
Prop
Type
The pending state lets a widget render a "Configuring mappings…" UI
distinct from booting. unreported fires after a timeout without any
section-API / stream payload — surface a friendly "host did not report
mappings" hint and keep the rest of the widget operable. invalidating
holds the previous complete/incomplete payload while a fresh one is
awaited, so the UI doesn't flicker back to an empty state.
Sync
Prop
Type
Prop
Type
A stream is hot while events arrive within budget, warming before the
first event, cold from the initial snapshot, and stale once the
freshness budget expires (see the reason field for why).
Heartbeat coalescence
The manager keeps the channel alive with a periodic probe (default 30 s,
calls grist.docApi.getDocName()). Every successful Grist RPC the SDK
issues through its slice hooks is reported back to the manager — the
heartbeat then skips the next scheduled probe because the natural
traffic already proved the link is healthy.
This is automatic when you mount <GristWidgetProvider>:
const writes = useGristWrites()
await writes.applyActions([["UpdateRecord", "Tasks", 1, { Done: true }]])
// → manager.recordRpcSuccess() fired internally, next heartbeat probe deferred.Failures call recordRpcFailure() instead, which shortens the next probe
to ≤ 1 s for fast re-confirmation. The single round-trip cost of a
false-positive (e.g. a semantic validation error misreported as a
transport failure) is the simplicity tax — we deliberately don't try to
distinguish error categories at this layer.
Manual coalescence
If you talk to Grist outside the SDK (e.g. raw grist.docApi.* calls or
your own REST layer), you can feed the manager yourself:
const { recordRpcSuccess, recordRpcFailure } = useGristHandshakeContext()
try {
const result = await myCustomRpc()
recordRpcSuccess()
// …
} catch (err) {
recordRpcFailure()
throw err
}Disabling the heartbeat
For widgets that are essentially write-only and never want a background probe:
<GristHandshakeProvider options={{ heartbeat: false }}>The link state then stays in unknown and canRender / canWriteRecords
gate on lifecycle.phase === "online" only.
reload() vs restart()
| Call | What it does |
|---|---|
reload() | Bumps the snapshot generation, cancels in-flight effects, restarts detect → negotiate. Stale callbacks from the previous generation are dropped. Clears the ensureGristReady singleton so a fresh grist.ready is issued. |
restart() | Calls reload(), then window.location.reload() (browser only — no-op in tests). |
Both leave the page's host realm untouched — neither tears down the iframe.
Generation discipline
The snapshot carries a monotonically increasing generation. Every
effect dispatch carries the generation it was scheduled with; the
reducer drops actions whose generation is older than the current one.
This guarantees that a slow grist.ready resolving after a reload()
can never restore an obsolete online phase.
Action-builders that talk to Grist outside the SDK should read
snapshot.generation once before sending and compare on return — the
manager's own internal effects already do this.