Skip to content

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…).

ts
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

GoalUse
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.

tsx
import { GristHandshakeProvider, useGristHandshakeContext } from "grist-widget-sdk/advanced"

<GristHandshakeProvider options={{ requiredAccess: "read table" }}>
  <App />
</GristHandshakeProvider>

function App() {
  const { snapshot, capabilities, reload } = useGristHandshakeContext()
  // …
}

Props

ts
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.

ts
const {
  snapshot,
  status,
  error,
  capabilities,
  reload,
  restart,
} = useGristHandshake({ requiredAccess: "read table" })

Returns:

ts
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

ts
const { snapshot, capabilities } = useGristHandshakeContext()       // throws if no provider
const ctx = useGristHandshakeContextOptional()                       // returns null if no provider

useGristHandshakeContext() 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.

ts
const { canRead, canRender, canWriteRecords, canWriteSchema } = useGristCapabilities()

if (!canRender) return <Skeleton />
return canWriteRecords ? <Editable /> : <ReadOnly />
ts
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

ts
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

ts
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().

ts
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

ts
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

ts
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>:

ts
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:

ts
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:

tsx
<GristHandshakeProvider options={{ heartbeat: false }}>

The link state then stays in unknown and canRender / canWriteRecords gate on lifecycle.phase === "online" only.

reload() vs restart()

CallWhat 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.

Released under the ISC License.