A click on a framework button crosses the wire: the client posts a ClickedEvent, the owner's
per-stream sync/{id} hub runs the control's click action, and only then does anything visible
happen. Without feedback, that gap reads as "the button did nothing" — and invites a second click.
So every framework button with a click action has a pending state, generically, with no code
in the button's author.
What the person sees
| Moment | Button |
|---|---|
| The click (synchronously, before any round trip) | disabled, a progress ring in place of its start icon, aria-busy="true", tooltip "Working…" (common.working, en + de) |
| A second click while pending | ignored — the click is submitted exactly once |
| The owner accepts | restored; if the button declares NavigateOnAccepted, the page navigates there instantly |
| The owner refuses (the action failed, or the stream was gone) | restored, and the reason is shown through the portal's error sink — never a silent reset |
A button without a click action (for example one that only carries WithNavigateToHref) never
pends: it has nothing to wait for.
What "done" means
The owner answers the click's receipt (UserActionAccepted) when the click action is done, and
answers a DeliveryFailure carrying the error when it fails. "Done" is the action's own completion
signal, normalised to ONE contract (UiControl.ClickAction is an IObservable<Unit> factory):
| The action is written as | Done when | Failed when |
|---|---|---|
WithClickAction(ctx => { …; return Task.CompletedTask; }) |
immediately, on the owner's turn — exactly as before | it throws |
WithClickAction(ctx => { … }) (an Action) |
immediately | it throws |
WithReactiveClickAction(ctx => someObservable) (an IObservable<Unit>) |
the observable completes (values are ignored) | it errors, or the action throws before returning it |
So a synchronous handler behaves as it always has, and a handler that wants the button to stay
pressed until its write is confirmed returns that write from WithReactiveClickAction — a
distinct name rather than a WithClickAction overload, because a lambda that fits both return types
(_ => throw …) would otherwise turn ambiguous in every existing caller, in-mesh sources included:
Controls.Button(texts.Approve)
.WithReactiveClickAction(ctx => ApproveAs(ctx.Host.Hub, access, caller, path) // GetMeshNodeStream(path).Update(…)
.Take(1)
.Select(_ => Unit.Default))
.WithNavigateOnAccepted(progressHref);
🚨 Return the confirmation of the WRITE the click requests — never the long-running work that
write triggers. An approval click writes approvedBy; the owning hub's watcher then runs the
deployment for minutes. The button must pend for the write (milliseconds), not the run: the run's
progress belongs on the page the button navigates to. An observable that never completes keeps the
button pending until the page goes away.
The framework subscribes the returned observable exactly once — do not also .Subscribe() it
inside the handler, or the write runs twice. Nothing here is async: no await, no .ToTask(), and
nothing parks the owner's turn; a still-running Task only registers a continuation, and the receipt
is a response (ResponseFor), so it carries the clicker's AccessContext whichever thread completes
the action.
Navigate-on-accepted
ButtonControl.WithNavigateOnAccepted(href) sets NavigateOnAccepted. On the owner's acceptance the
client navigates to href immediately — without waiting for anything the action started. Compare:
| Property | Navigates | Use for |
|---|---|---|
NavigateToHref |
at the click, before anything is sent | plain links styled as buttons |
NavigateOnAccepted |
on the owner's acceptance; a refused click stays on the page and says why | "do X, then show me X happening" — approve → the run's live progress |
Client contract
The receipt travels through the existing acknowledged sender
(ISynchronizationStream.SubmitUserAction, see
Refusing a Lost User Action); its five-argument overload
adds onAccepted, the other end of the pending state:
stream.SubmitUserAction(new ClickedEvent(area, stream.StreamId), actingUser,
onRefused: sentence => { /* restore + show the sentence */ },
onAccepted: () => { /* restore, then navigate if NavigateOnAccepted is set */ });
Both callbacks run on whichever thread delivered the receipt; a view marshals back onto its own
dispatcher (InvokeAsync) before touching component state. The Blazor ButtonView (in
MeshWeaver.Plugins) is the reference implementation.
See also
- Layout Areas — click handlers and navigation between areas
- Refusing a Lost User Action — the receipt this state waits on
- Asynchronous Calls — why handlers compose observables instead of awaiting