Docs Platform

Runner

Machines describe what they want. Runner is how it happens: one framework-free package, no DOM and no adapters, that every async component in this library consumes the same way.

Live machine

The shipped runMachine — the whole run lifecycle as state values: submitted, streaming, settled, cancelled, failed, and the action-required detour.

run · live via createService

idleattempt 0

States · walked from the machine

idlesubmittedstreamingsettledcancelledfailedrequires-action

Transitions from here · click to send

Context

{
  "attempt": 0
}

Log · newest first

No events yet — use the component above or send a transition.

Hello world

A settle-once request can report directly to a service. The runner owns cancellation and delivery; the machine receives ordinary events.

import { createService } from "@grassroot/statechart"
import { createRequestRunner } from "@grassroot/runner"

const runner = createRequestRunner({
  source: (signal) => fetch("/api/profile", { signal }).then((r) => r.json()),
  send: (event) => service.send(event), // { type: "RESULT", result } | { type: "FAILED", error }
})
await runner.start()

The command bridge keeps the same boundary when a machine initiates the work: the machine says what should happen and Runner handles how.

import { bindRunnerToService } from "@grassroot/runner"

const runCommand = bindRunnerToService({
  handlers: {
    OPTIONS_REQUEST: (request, { signal }) => fetchOptions(request.query, signal),
  },
})
const service = createService(comboboxMachine, { input, runCommand })

The command bridge

Combobox's optional loadOptions mode emits this keyed command after its debounce. A newer request for the same key supersedes the earlier one.

{ type: "OPTIONS_REQUEST", key: "combobox.options", request: { query: context.inputValue },
  onSettled: { type: "OPTIONS_LOADED", payloadKey: "options" },
  onFailed: { type: "OPTIONS_LOAD_FAILED", payloadKey: "error" } }

Wire that command to real work with bindRunnerToService. The signal aborts when the request is cancelled or superseded.

const runCommand = bindRunnerToService({
  handlers: { OPTIONS_REQUEST: (request, { signal }) => loadOptions(request.query, signal) },
})
const service = createService(comboboxMachine, { input: { options: [], loadOptions: true }, runCommand })

While loading, Combobox keeps its last-known options. A successful request replaces them; a failed request records the error without emptying the list. With loadOptions absent, the existing synchronous behaviour is unchanged.

Keyed staleness

createKeyedRunner allows one live run per key. These five scenarios define its same-key-replace contract.

#
Scenario
Expected behaviour
Red-first test name
1
start(key, req) while key already has a live run
The live run's AbortSignal aborts synchronously, its map entry is removed, then the new run's spawn is invoked
same-key start cancels the in-flight run before spawning the replacement
2
cancel(key) with no live entry for key
No-op: no send, no diagnostic, no throw
cancel on an unknown key is a no-op
3
cancel(key) mid-flight, then the cancelled promise later resolves
The resolution never reaches send, guarded by stream.ts's existing abortController.signal.aborted check inside emit, inherited unchanged
cancel discards the in-flight result; a late resolve after cancel never sends
4
stopAll() with two live keys, one of which later resolves
Both are cancelled; the later resolution of either never sends; a subsequent start() on either key after stopAll() behaves as a normal fresh start (not itself an error)
stopAll cancels every live key; a late result from either never sends, and a new start after stopAll is not itself rejected
5
Three rapid same-key starts (generations 1, 2, 3), where generation 1's promise resolves after generation 3 has started
Only generation 3's eventual result ever sends; generation 1's and 2's resolutions are both discarded, regardless of arrival order
a third same-key start supersedes a still-pending first generation; only the third generation's result ever sends
#
1
Scenario
start(key, req) while key already has a live run
Expected behaviour
The live run's AbortSignal aborts synchronously, its map entry is removed, then the new run's spawn is invoked
Red-first test name
same-key start cancels the in-flight run before spawning the replacement
#
2
Scenario
cancel(key) with no live entry for key
Expected behaviour
No-op: no send, no diagnostic, no throw
Red-first test name
cancel on an unknown key is a no-op
#
3
Scenario
cancel(key) mid-flight, then the cancelled promise later resolves
Expected behaviour
The resolution never reaches send, guarded by stream.ts's existing abortController.signal.aborted check inside emit, inherited unchanged
Red-first test name
cancel discards the in-flight result; a late resolve after cancel never sends
#
4
Scenario
stopAll() with two live keys, one of which later resolves
Expected behaviour
Both are cancelled; the later resolution of either never sends; a subsequent start() on either key after stopAll() behaves as a normal fresh start (not itself an error)
Red-first test name
stopAll cancels every live key; a late result from either never sends, and a new start after stopAll is not itself rejected
#
5
Scenario
Three rapid same-key starts (generations 1, 2, 3), where generation 1's promise resolves after generation 3 has started
Expected behaviour
Only generation 3's eventual result ever sends; generation 1's and 2's resolutions are both discarded, regardless of arrival order
Red-first test name
a third same-key start supersedes a still-pending first generation; only the third generation's result ever sends

Who consumes this

Runner is the shared async boundary for five named consumers. Combobox ships the integration here; the remaining product plans now point to the same public package instead of defining parallel actor seams.

Consumer
Interface it consumes
Status here
Combobox async-options
bindRunnerToService({ handlers: { OPTIONS_REQUEST } }) over createKeyedRunner
Built in R-1 (Steps 6, 8, 9)
Grid G-6 (server-side row model)
createKeyedRunner, real AsyncIterable<T> per row-block key, through bridge.ts's onDelta
Amendment landed in GRASSROOT_DATA_GRID_PLAN.md
Form F-3 (async field validators)
bindRunnerToService keyed by field path, onSettled: { type: "VALIDATION_RESULT" }
Amendment landed in GRASSROOT_FORM_PLAN.md
Editor E-5 (image upload)
bindRunnerToService/createRequestRunner keyed by upload id
Amendment landed in GRASSROOT_RICH_TEXT_EDITOR_PLAN.md
Devtools DT-4 (remote transport reconnect)
withRetry over a settle-once RequestRunnerOptions<T> factory
Amendment landed in GRASSROOT_DEVTOOLS_PLAN.md
Consumer
Combobox async-options
Interface it consumes
bindRunnerToService({ handlers: { OPTIONS_REQUEST } }) over createKeyedRunner
Status here
Built in R-1 (Steps 6, 8, 9)
Consumer
Grid G-6 (server-side row model)
Interface it consumes
createKeyedRunner, real AsyncIterable<T> per row-block key, through bridge.ts's onDelta
Status here
Amendment landed in GRASSROOT_DATA_GRID_PLAN.md
Consumer
Form F-3 (async field validators)
Interface it consumes
bindRunnerToService keyed by field path, onSettled: { type: "VALIDATION_RESULT" }
Status here
Amendment landed in GRASSROOT_FORM_PLAN.md
Consumer
Editor E-5 (image upload)
Interface it consumes
bindRunnerToService/createRequestRunner keyed by upload id
Status here
Amendment landed in GRASSROOT_RICH_TEXT_EDITOR_PLAN.md
Consumer
Devtools DT-4 (remote transport reconnect)
Interface it consumes
withRetry over a settle-once RequestRunnerOptions<T> factory
Status here
Amendment landed in GRASSROOT_DEVTOOLS_PLAN.md