Skip to content

fix(ToggleButton): run pressedChangeAction in a transition for pending state#3056

Merged
cixzhang merged 3 commits into
mainfrom
navi/fix/togglebutton-pressedchangeaction-transition
Jun 25, 2026
Merged

fix(ToggleButton): run pressedChangeAction in a transition for pending state#3056
cixzhang merged 3 commits into
mainfrom
navi/fix/togglebutton-pressedchangeaction-transition

Conversation

@cixzhang

@cixzhang cixzhang commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

ToggleButton's pressedChangeAction was 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:

  • Optimistic stateuseOptimistic flips the pressed state immediately on click; the action runs inside useTransition.
  • Interruptible — the button stays clickable while the action is pending. Clicking again starts a new transition with the next optimistic state (e.g. true → false → true) instead of being dropped or blocked behind a disabled spinner. No re-entry guard.
  • Sync + suspending handlers — a pressedChangeAction (or onPressedChange) that synchronously triggers a suspending update (e.g. a router navigation that suspends on data) also drives the pending state. pressedChangeAction now accepts void | Promise<void>.

New: Button.isInterruptible

To show a pending spinner without disabling the button, this adds a Button prop:

isInterruptible?: boolean // default false

When set, the loading state still renders the spinner and aria-busy, but the button is not disabled — so clicks keep landing. ToggleButton passes it. Defaults to false, so existing isLoading behavior (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

  • ToggleButton: optimistic state shows + button stays interruptible (clickable, aria-busy) while pending; loading clears on settle; rapid re-clicks interrupt (true → false → true); sync action supported.
  • Button: isInterruptible shows aria-busy but keeps the button clickable; default isLoading still disables.
  • Full ToggleButton + Button suites pass; core typecheck, doc typecheck, SYNC check, and lint clean.

Notes

  • Group mode (inside ToggleButtonGroup) delegates to group.toggle(value) and has no async-action path; scoped out of this PR.
  • A follow-up (stacked) adds a preventDefault opt-out on onPressedChange.

…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.
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
astryx Ignored Ignored Jun 25, 2026 4:49pm

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Jun 24, 2026
@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

🚀 Vercel Preview Deployment

Status ✅ Deployed
Preview Open Preview
Commit 5f5fe87
Inspect Vercel Dashboard
Workflow View Logs

No authentication required — anyone with the link can view the preview.

@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

No new or modified components detected.

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.6KB 0B

Accessibility Audit

Status: 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
github-actions Bot added a commit that referenced this pull request Jun 25, 2026
github-actions Bot added a commit that referenced this pull request Jun 25, 2026
@cixzhang cixzhang merged commit d3c9e29 into main Jun 25, 2026
15 checks passed
// 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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this is delayed with a css animation-delay.

const newState = !optimisticPressed;
startTransition(async () => {
setOptimisticPressed(newState);
onPressedChangeProp?.(newState);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could consider passing the event here, which allows calling preventDefault so you can do:

onPressedChangeProp?.(event);
if (!event.defaultPrevented) {
  await pressedChangeAction?.(newState);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants