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