- #
- 1
- Scenario
start(key, req)whilekeyalready has a live run- Expected behaviour
- The live run's
AbortSignalaborts synchronously, its map entry is removed, then the new run'sspawnis invoked - Red-first test name
same-key start cancels the in-flight run before spawning the replacement
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
States · walked from the machine
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.
start(key, req) while key already has a live runAbortSignal aborts synchronously, its map entry is removed, then the new run's spawn is invokedsame-key start cancels the in-flight run before spawning the replacementcancel(key) with no live entry for keysend, no diagnostic, no throwcancel on an unknown key is a no-opcancel(key) mid-flight, then the cancelled promise later resolvessend, guarded by stream.ts's existing abortController.signal.aborted check inside emit, inherited unchangedcancel discards the in-flight result; a late resolve after cancel never sendsstopAll() with two live keys, one of which later resolvesstart() 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 rejecteda third same-key start supersedes a still-pending first generation; only the third generation's result ever sends- #
- 2
- Scenario
cancel(key)with no live entry forkey- 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 bystream.ts's existingabortController.signal.abortedcheck insideemit, 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 afterstopAll()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.
async-optionsbindRunnerToService({ handlers: { OPTIONS_REQUEST } }) over createKeyedRunnercreateKeyedRunner, real AsyncIterable<T> per row-block key, through bridge.ts's onDeltaGRASSROOT_DATA_GRID_PLAN.mdbindRunnerToService keyed by field path, onSettled: { type: "VALIDATION_RESULT" }GRASSROOT_FORM_PLAN.mdbindRunnerToService/createRequestRunner keyed by upload idGRASSROOT_RICH_TEXT_EDITOR_PLAN.mdwithRetry over a settle-once RequestRunnerOptions<T> factoryGRASSROOT_DEVTOOLS_PLAN.md- Consumer
- Combobox
async-options - Interface it consumes
bindRunnerToService({ handlers: { OPTIONS_REQUEST } })overcreateKeyedRunner- Status here
- Built in R-1 (Steps 6, 8, 9)
- Consumer
- Grid G-6 (server-side row model)
- Interface it consumes
createKeyedRunner, realAsyncIterable<T>per row-block key, throughbridge.ts'sonDelta- Status here
- Amendment landed in
GRASSROOT_DATA_GRID_PLAN.md
- Consumer
- Form F-3 (async field validators)
- Interface it consumes
bindRunnerToServicekeyed 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/createRequestRunnerkeyed by upload id- Status here
- Amendment landed in
GRASSROOT_RICH_TEXT_EDITOR_PLAN.md
- Consumer
- Devtools DT-4 (remote transport reconnect)
- Interface it consumes
withRetryover a settle-onceRequestRunnerOptions<T>factory- Status here
- Amendment landed in
GRASSROOT_DEVTOOLS_PLAN.md