Skip to content

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

Open
cixzhang wants to merge 2 commits into
mainfrom
navi/fix/togglebutton-pressedchangeaction-transition
Open

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

Conversation

@cixzhang

Copy link
Copy Markdown
Contributor

Summary

ToggleButton's pressedChangeAction prop is documented as an async action handler that shows a loading spinner while the promise is pending (see the prop's JSDoc and ToggleButton.doc.mjs). In practice the action was fired and dropped:

if (pressedChangeAction) {
  void pressedChangeAction(newState); // not awaited — no pending state, no re-entry guard
}

So the spinner never appeared, and rapid clicks could fire the action multiple times before it settled.

This wires pressedChangeAction through useTransition, matching how Button's clickAction already works:

  • While the action is in flight the button shows its loading state (disabled + aria-busy).
  • Re-entrant clicks are ignored until the action settles (actionInFlightRef guard).
  • No API change — pressedChangeAction's signature is unchanged, and the synchronous onPressedChange still fires first.

ToggleButton is a thin wrapper over Button, so the pending state is fed into the Button's existing isLoading path (isLoading || isPending) and renders the same spinner.

Test plan

  • Added a test asserting the button is disabled + aria-busy while pressedChangeAction is pending, and clears once it resolves.
  • Added a test asserting re-entrant clicks are ignored while the action is pending (action fires once).
  • pnpm --filter @astryxdesign/core typecheck and the full ToggleButton suite (22 tests) pass.

Notes / follow-up

  • Group mode (inside ToggleButtonGroup) delegates to group.toggle(value) and has no async-action path; this PR intentionally scopes to the standalone pressedChangeAction behavior. Happy to follow up on whether group-backed toggles should support an async action too.
  • A useOptimistic-style optimistic toggle (like Switch) was considered but left out: isPressed is consumer-controlled, so revert-on-failure semantics are ambiguous. This change keeps the minimal, documented behavior (auto spinner + re-entry guard).

…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 12:18am

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 c628f63
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
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