fix(ToggleButton): run pressedChangeAction in a transition for pending state#3056
Conversation
…g state pressedChangeAction was fired as a non-awaited promise, so the documented loading spinner never appeared and rapid clicks could fire the action multiple times. Wrap it in useTransition so the button shows its loading state (disabled + aria-busy) while the async action is pending, and guard against re-entrant clicks until it settles — matching Button's clickAction.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
🚀 Vercel Preview Deployment
|
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR No new or modified components detected. Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
| await user.click(button); | ||
| await user.click(button); | ||
|
|
||
| // Second click is a no-op while the first action is still pending. |
There was a problem hiding this comment.
Wait why? It should call the action again.
For example, imagine this use case:
<ToggleButton
label="Favorite"
isPressed={false}
onPressedChange={() => {
setChecked(c => !c);
}}
pressedChangeAction={pressedChangeAction}
/>
If you click twice, it should go from true -> false (interrupted) -> true. The optimistic value would show true, false, true.
|
|
||
| // Wrap pressedChangeAction in a transition so the button shows a loading | ||
| // spinner while the async action is pending and re-entrant clicks are | ||
| // ignored until it settles. Mirrors Button's clickAction handling. |
There was a problem hiding this comment.
Yeah the benefit of the action pattern is that you don't have to guard against re-entrant clicks.
| expect(screen.getByTestId('bold-toggle')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('shows a loading spinner while pressedChangeAction is pending', async () => { |
There was a problem hiding this comment.
We should add a test and support sync actions.
For example, this should work:
<ToggleButton
label="Favorite"
isPressed={false}
onPressedChange={(pressed) => {
// this is NOT async
//
// routes may suspend on data, so loading will stay until
// we can either complete with suspense fallback, or the
// transition can complete with content. if the user clicks
// again while that is waiting, then it's interrupted to handle
// the new navigation.
if (pressed) {
router.go('/a');
} else {
router.go('/b');
}
}}
pressedChangeAction={pressedChangeAction}
/>| actionInFlightRef.current = true; | ||
| startTransition(async () => { | ||
| try { | ||
| await pressedChangeAction(newState); |
There was a problem hiding this comment.
The newState here needs to be tracked as an optimistic state. Because if you click while an action is in progress, it should be the current in progress value (which optimistic tracks) not the old value. This is a more obvious bug after you remove the actionInFlightRef entrance guard, and debounce the loading spinner so that it immediately shows the optimistic state.
…stic state Address review feedback: model the toggle on Switch's useOptimistic pattern. - Track pressed state optimistically so the new state shows immediately and a click mid-flight derives its next value from the in-progress state (true -> false -> true), instead of stalling on the committed value. - Remove the re-entry guard: the action is now interruptible. Re-clicks start a new transition rather than being dropped. - Run onPressedChange + pressedChangeAction inside the transition so a sync but suspending handler (e.g. a router navigation that suspends on data) also drives the pending state. pressedChangeAction now accepts void | Promise<void>. - Debounce the loading spinner so fast actions show the optimistic state without a spinner flash and stay interruptible.
| // Both onPressedChange and pressedChangeAction run inside the transition, | ||
| // which means a synchronous-but-suspending handler (e.g. a router navigation | ||
| // that suspends on data) also drives the pending state — not just promises. | ||
| const [isPending, startTransition] = useTransition(); |
There was a problem hiding this comment.
There's another option here, which has trade offs to consider: you can use the isomorphic start transition and check isPending with:
const isPending = optimisticPressed !== committedPressed;
The subtle tradeoffs are that errors thrown inside action will not re-throw from isomorphic startTransition but will re-throw for useTransition which may or may not be the behavior you want.
Also, by using the optimistic state for isPending, if the user clicks false -> true -> false, then when it's back to the original value optimistically then isPending is back to false. Which may or may not be what you want.
| const [isPending, startTransition] = useTransition(); | ||
| // Debounce the spinner so a fast action shows the optimistic state without a | ||
| // spinner flash, and rapid re-clicks can interrupt before the button locks. | ||
| const showSpinner = useDelayed(isPending, PENDING_SPINNER_DELAY_MS); |
There was a problem hiding this comment.
Ideally this is delayed with a css animation-delay.
| const newState = !optimisticPressed; | ||
| startTransition(async () => { | ||
| setOptimisticPressed(newState); | ||
| onPressedChangeProp?.(newState); |
There was a problem hiding this comment.
you could consider passing the event here, which allows calling preventDefault so you can do:
onPressedChangeProp?.(event);
if (!event.defaultPrevented) {
await pressedChangeAction?.(newState);
}
Summary
ToggleButton'spressedChangeActionwas documented as an async action handler that shows a loading spinner while pending, but the action was fired and dropped (void pressedChangeAction(newState)) — so the spinner never appeared and the toggle ignored the action's lifecycle.This makes the toggle properly action-driven and interruptible, matching
Switch:useOptimisticflips the pressed state immediately on click; the action runs insideuseTransition.true → false → true) instead of being dropped or blocked behind a disabled spinner. No re-entry guard.pressedChangeAction(oronPressedChange) that synchronously triggers a suspending update (e.g. a router navigation that suspends on data) also drives the pending state.pressedChangeActionnow acceptsvoid | Promise<void>.New:
Button.isInterruptibleTo show a pending spinner without disabling the button, this adds a
Buttonprop:When set, the loading state still renders the spinner and
aria-busy, but the button is not disabled — so clicks keep landing.ToggleButtonpasses it. Defaults tofalse, so existingisLoadingbehavior (spinner + disabled) is unchanged.This replaces an earlier spinner-debounce hack (
useDelayed+ a 150ms window): the button no longer races a timer to stay interruptible — pending simply doesn't disable it. The spinner is also suppressed for purely-local toggles (no async action), so a plain toggle never flashes a spinner.Test plan
aria-busy) while pending; loading clears on settle; rapid re-clicks interrupt (true → false → true); sync action supported.isInterruptibleshowsaria-busybut keeps the button clickable; defaultisLoadingstill disables.Notes
ToggleButtonGroup) delegates togroup.toggle(value)and has no async-action path; scoped out of this PR.preventDefaultopt-out ononPressedChange.