Handshake module
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()
// …
}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 />type GristCapabilities = {
/** Read APIs are safe to call (`fetchTable`, `getDocName`, …). */
canRead: boolean
/** The current selection is fresh enough to drive UI (`record` is current). */
canRender: boolean
/** Write APIs are safe (`requiredAccess` ≥ "full" AND link is healthy). */
canWriteRecords: boolean
/** Schema-mutating actions are safe (`requiredAccess` ≥ "full" AND no error). */
canWriteSchema: boolean
/** Same as `canRead` AND a table id is known. */
canFetchTable: boolean
/** `record` was received within the freshness budget (default 30 s). */
hasFreshSelection: boolean
}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
type GristWidgetSnapshot = {
generation: GristGeneration // bumped on reload/restart
lifecycle: GristLifecycle // phase + termination reason
link: GristLink // connected/stale/lost/unknown
authz: GristAuthz // requested/granted access
config: GristConfig // requiredAccess, columns, mapping state
sync: GristSync // record/records/options/newRecord freshness + current table
}Lifecycle
type GristLifecycle =
| { phase: "idle" }
| { phase: "detecting"; startedAtMs: number; attempts: number }
| { phase: "negotiating"; startedAtMs: number }
| { phase: "online"; sinceMs: number }
| { phase: "terminated"; reason: GristTerminationReason }
type GristTerminationReason =
| { kind: "not_embedded" }
| { kind: "script_timeout" }
| { kind: "negotiate_failed"; cause: string }
| { kind: "manual_stop" }terminated is absorbing — no action can revive it without a reload().
Link
type GristLink = {
state: "unknown" | "connected" | "stale" | "lost"
lastSuccessMs: number | null
missedPings: number
}The heartbeat moves the link through connected → stale → lost based on missed probes (see Heartbeat below). lost escalates to global status === "error".
Config / mappings
type GristConfig = {
requiredAccess: GristRequiredAccess
declared: GristColumnsToMap
mappings:
| { state: "not_declared" }
| { state: "pending"; declaredAtMs: number; previous?: GristMappings }
| { state: "resolved"; reportedAtMs: number; state2: GristMappings }
| { state: "unreported"; declaredAtMs: number; lastTimeoutMs: number }
| { state: "invalidated"; reason: string; previous?: GristMappings }
}The pending state lets a widget render a "Configuring mappings…" UI distinct from booting. unreported fires after 5 s without any section-API / stream payload — surface a friendly "host did not report mappings" hint and keep the rest of the widget operable.
Sync
type GristSync = {
record: GristStreamFreshness
records: GristStreamFreshness
options: GristStreamFreshness
newRecord: GristStreamFreshness
currentTable: GristCurrentTableState
}
type GristStreamFreshness = {
status: "unknown" | "warm" | "cold" | "stale"
lastSeenMs: number | null
/** Compact hash of the last payload — equal hash ⇒ same payload (idempotence). */
lastPayloadHash: string | null
}A stream is warm while events arrive within budget, cold if it has never fired, and stale after the freshness budget expires (default 30 s for record/records).
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.