Summary#
When a control's action waits on the server — an onClick/onEnter that calls a server function and hasn't come back
yet — the platform gives the user feedback automatically: after a short delay the control shows a busy spinner and
disables itself until the action finishes. You write nothing; every button that hits the server gets it.
The delay is deliberate. Most actions are fast, and a spinner that flashes for 40ms is worse than none — it reads as a glitch. So the spinner appears only once an action has run longer than the threshold (200ms by default), and once shown it stays visible for a short minimum so it can't blink off the instant the answer arrives. A fast action shows nothing.
Disabling the control while it is busy also blocks an accidental double-submit — a second click on a half-second "Save" does nothing rather than firing the action twice.
When you want to say something specific while one particular verb runs — a button that reads "Reading…" rather than
just spinning — read that verb's own flag: Read.Pending is true while an invocation of Read is in flight. It follows
the one action it names, so a page that saves while a read is running does not relabel the read button.
For a page-wide busy affordance — a top progress bar, a dimmed overlay — read the Pending ambient: Pending.Any
is true while any action is in flight, Pending.Count is how many. That is the one thing you author; the per-control
spinner needs no code at all.
Three grains, and picking the wrong one is the usual mistake:
| You want | Read | Grain |
|---|---|---|
| A spinner on the control that was pressed | nothing — it is automatic | that control |
| A label or field that speaks for ONE verb | save.Pending | that action |
| A top bar / overlay for the whole page | Pending.Any · Pending.Count | the page |
Signature#
// Automatic — no code. A control whose action suspends on the server shows a delayed spinner + disables itself.
save.Pending // true while an invocation of THIS action is in flight (a bool) — per-verb
Pending.Any // true while at least one action is in flight (a bool) — for your own global busy surface
Pending.Count // how many actions are in flight right now (an int)
// Tune the timing (and, later, supply your own spinner) on app.Ui:
app.Ui = new AppUi {
PendingDelayMs = 200, // show the spinner only after an action runs this long (0 → the 200ms default)
PendingMinShowMs = 300, // once shown, keep it at least this long so it can't blink off (0 → the 300ms default)
};Description#
The busy affordance is mechanism, not chrome. The platform draws a plain spinner (.osy-spinner) beside the
control's label and applies the ordinary disabled styling; an app that wants a different look styles it in its own CSS,
the same way it styles any other control state.
It is entirely client-side. The first server-rendered paint is never busy (there is nothing pending yet), so a page
reading Pending.Any renders its idle state on the server and lights up only once the user starts something.
Which events count: the spinner is for activation events — onClick, onEnter, onSubmit, and an Upload's
onUploaded. Typing and focus events (onInput, onChange, onBlur) do not spin the field — a keystroke that runs a
check should not make the input look busy. The Pending ambient counts every in-flight action regardless.
Pending.Any/Pending.Count update the instant an action begins and settles — the ambient is the raw in-flight
count, not the delayed spinner. So a top bar bound to Pending.Any appears immediately; if you want it to respect the
same "don't flash" delay, gate it on your own timer.
<verb>.Pending is available on every action and method a component declares, and reads as an ordinary bool
anywhere an expression is legal — a ternary, a prop value, an if. Like the ambient it flips the instant the action
begins, with no delay: it is what you reach for when you would rather say "Reading…" than show a generic spinner, and
you want that word to appear at once.
It counts every invocation of that action, not only the one a control activated — an action called from another action is still running, and a label claiming otherwise would be wrong in the one case you most want to explain. It also clears when an action fails: the work is over, it just went badly, and a button stuck reading "Reading…" forever is worse than one that goes back to normal while your failure surface says what happened.
Actions are serialised per page, so an action can sit queued behind another. .Pending is true for that wait too — from
the user's side the work has already started, and the label is what tells them so.
A misspelling is a compile error naming what exists (save.Pendign → "a verb has no member 'Pendign' (Pending)"),
rather than a value that silently reads as nothing.
PendingDelayMs / PendingMinShowMs are app-wide timing, read from app.Ui. Leave them out (or set 0) and the
platform defaults apply. A negative value is a compile error.
Examples#
The common case is nothing — the spinner is automatic:
component InvitePage() {
action Accept() { var t = AcceptInvite(token, email, password); Session.SignIn(t); }
render {
// No pending code. `Accept` calls a server function, so this button spins + disables while it runs.
Pressable(onClick: Accept) { Text("Accept invitation"); }
}
}AcceptInvite here is a server function reached by a signed-out visitor, so a working version of this also needs it
marked [AuthMethod] and wired into app.AuthBootstrap — see [AuthMethod] — a function an unauthenticated visitor may call. The point above is only that
the spinner costs no code either way.
A button that says what it is doing, using the action's own flag:
component ExpenseLineRow(ExpenseLine line) {
action Read() { ReadReceipt(line); }
render {
Pressable(onClick: Read) {
// Follows `Read` alone — another action finishing elsewhere on the page leaves this label untouched.
Text(Read.Pending ? "Reading…" : "Read receipt");
}
}
}The same flag on a control that takes a busy prop, and to keep a form from being resubmitted:
component LoginCard() {
action SignIn() { Session.SignIn(Authenticate(email, password)); }
render {
Button("Sign in", onPress: SignIn, busy: SignIn.Pending, disabled: email == "");
}
}A page-wide top progress bar, using the Pending ambient:
theme App { Colors { Accent = "#0077B6"; } } // `Accent` is your own token, not a platform one
[Layout]
component Shell() {
render {
if (Pending.Any) { Box(h: "2px", bg: Colors.Accent, w: "100%"); } // a thin bar while anything is in flight
Outlet();
}
}Tuning the timing for a whole app:
app.Ui = new AppUi {
PendingDelayMs = 120, // this app's actions are usually instant — show the spinner sooner when they aren't
PendingMinShowMs = 400, // …but once it shows, hold it a beat longer
};See also#
- UI surfaces (app.Ui) — the
app.Uiblock that tunes the timing (and nominates the app's system surfaces). - Connection — the sibling surface for the server-dropped case (a full connection-loss overlay).
- component — the components and controls the spinner attaches to.