Skip to content

fix(core): keep busy Button focusable, recover from failed clickAction - #4879

Closed
AKnassa wants to merge 2 commits into
facebook:mainfrom
AKnassa:rocky/issue-4871-button-busy-focus
Closed

AKnassa wants to merge 2 commits into
facebook:mainfrom
AKnassa:rocky/issue-4871-button-busy-focus

Conversation

@AKnassa

@AKnassa AKnassa commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #4871

What happens today

While a clickAction is pending (or isLoading is set), Button sets the native disabled attribute. A natively disabled element cannot hold focus, so the browser drops keyboard focus to <body> the moment the action starts — the next Tab restarts from the top of the document, and nothing is announced when the action completes. This is the deviation already documented in the API Conventions wiki ("Disabled vs Busy" → Known deviation).

What this PR does

Busy no longer uses the native attribute (first commit):

  • Only hard-disabled (isDisabled / ButtonGroup disabled) may set native disabled; that behavior is unchanged.
  • A busy button stays focusable and announces aria-busy="true" + aria-disabled="true".
  • Re-activation is blocked in the handlers instead: the existing handleClick ref guard covers the pointer path, and the existing aria-disabled keydown path (previously tooltip-only) now also suppresses Enter/Space while busy. Fire-once actions still fire exactly once — the same-tick double-click dedupe is untouched.
  • With href, a busy button now stays an anchor instead of swapping to a disabled <button> mid-action — the element swap dropped focus the same way the attribute did.
  • isInterruptible is unchanged (interactive while pending: aria-busy without aria-disabled).

A rejected clickAction no longer bricks the button (second commit, found while stress-testing the busy state):

  • The rejection used to escape the startTransition async scope, so the transition never settled — isPending stuck true, the spinner never left, and the guard blocked every retry until remount. Latent on main too, where it presented as disabled-forever.
  • The action now catches the rejection, reports it via devError (the established runtime-failure channel, cf. Table plugins), and settles; the finally still resets the in-flight guard so the user can retry.

Behavior table

State disabled attr aria-busy aria-disabled Focusable Activation
idle – – – yes fires
busy (pending action / isLoading) – ✓ ✓ yes guarded in handlers
busy + isInterruptible – ✓ – yes fires (interrupts)
isDisabled (no tooltip) ✓ – – no blocked
isDisabled + tooltip – – ✓ yes (to reach tooltip) blocked
isDisabled + isLoading ✓ ✓ – no blocked

Testing

  • 26 Button tests around this contract, including 11 edge cases: tooltip+busy, isDisabled+isLoading precedence, ButtonGroup disabled (with/without tooltip), interruptible boundaries, form-submit suppression while busy, disabled-link fallback, busy-anchor focusability, and rejected-action recovery. Written red-first against the old code.
  • Full suite: 9547 pass; the only 2 failures are the pre-existing packages/cli name-resolution failures, identical on an untouched main baseline.
  • Real-browser check (Storybook, Chromium): busy button has no disabled attribute, Tab lands on it, and the :focus-visible ring renders around the spinner.
  • Core typecheck, check:repo (incl. changesets), eslint CI=true, prettier: all green. Changeset included (@astryxdesign/core patch).

Notes for review

  • aria-disabled="true" on the busy anchor: valid ARIA, but if you'd rather busy links carry only aria-busy, that's a one-line change.
  • Busy keeps the existing disabled visual (50% opacity, not-allowed cursor) — conforms to the wiki table ("reduced opacity"); no visual delta intended.
  • Every other startTransition(async …) call site in core (Selector, Pagination, ComplexSelector, TimeInput, …) has the same unguarded await and can strand isPending on a rejected action. Deliberately not touched here — happy to follow up separately.
  • The wiki's "Known deviation" paragraph under Disabled vs Busy can be deleted when this merges.

While a clickAction is pending (or isLoading is set), Button set the
native disabled attribute. A natively disabled element cannot hold
focus, so the browser dropped keyboard focus to <body> the moment the
action started, and it was never restored when the action settled.

Busy is now expressed the way the API Conventions "Disabled vs Busy"
rule specifies: no native disabled, aria-busy + aria-disabled for AT,
and re-activation (click, Enter, Space) blocked through the existing
handleClick/handleKeyDown guards — so fire-once actions still fire
exactly once. With href, a busy button now stays an anchor instead of
swapping to a disabled <button> mid-action, which dropped focus the
same way.

isDisabled (and ButtonGroup disabled) behavior is unchanged: still the
native attribute, aria-disabled only when a tooltip needs a focusable
trigger. isInterruptible is unchanged.

Fixes facebook#4871
Stress-testing the busy state surfaced a second defect on the same
lines: a rejected clickAction escaped the startTransition async scope,
so React never settled the transition. isPending stuck true, the
spinner never left, and the activation guard blocked every retry — one
failed action bricked the button until remount. (Latent on main too,
where it presented as disabled-forever.)

The action now catches the rejection, reports it via devError (the
established runtime-failure channel, see Table plugins), and settles
normally; the finally still resets the in-flight guard, so the user
can retry.

Also adds an edge-case suite around the busy contract: tooltip+busy,
isDisabled+isLoading precedence (native attribute wins), ButtonGroup
disabled with and without tooltip, interruptible boundaries (aria-busy
without aria-disabled; isDisabled wins over isInterruptible), form
submit suppression while busy, disabled-link fallback, busy-anchor
focusability, and rejected-action recovery.

Refs facebook#4871
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

@AKnassa is attempting to deploy a commit to the Meta Open Source Team on Vercel.

A member of the Team first needs to authorize it.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 11, 2026
@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Aug 11, 2026
@github-actions

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.

Modified Components

Button (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 566 -
Complexity N/A Very High (66) -
IconButton (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 17 -
Complexity N/A Low (1) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.7KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@AKnassa
AKnassa marked this pull request as ready for review August 11, 2026 03:49
github-actions Bot added a commit that referenced this pull request Aug 11, 2026
HelloOjasMutreja added a commit to HelloOjasMutreja/astryx that referenced this pull request Sep 10, 2026
A busy-only href Button still set renderAsLink = href != null &&
!buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a
busy link swapped to a disabled <button> mid-action, dropping focus the
same way the native disabled attribute the earlier commit removed did.
renderAsLink now checks isTrueDisabled only; a genuinely disabled href
Button still falls back (disabled links are an accessibility
anti-pattern), but busy-only stays an anchor with aria-busy and
aria-disabled applied directly to it, guarded by the same handleClick
ref check that already suppresses activation. Carries over and credits
@AKnassa's anchor-preservation work from facebook#4879.

Reconciled the Chromium a11y contract's own accounting: the fix turns
button.focus.reachable-and-escapable and button.unavailable.inert from
known-failure into unexpected-pass for both Button and IconButton's
loading state (verified against a real Chromium run before and after),
so removed the four now-stale known-failure records and the two
declaredNotDelivered entries they were paired with.

Updated Button and IconButton's own consumer docs (isLoading no longer
disables interaction; it blocks re-activation while staying focusable)
and ListInput's loading-state test, whose add-item button receives
isLoading directly and is now aria-disabled rather than natively
disabled while busy — its remove/reorder controls map ListInput's
loading state onto isDisabled instead and are unaffected.
HelloOjasMutreja added a commit to HelloOjasMutreja/astryx that referenced this pull request Sep 21, 2026
A busy-only href Button still set renderAsLink = href != null &&
!buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a
busy link swapped to a disabled <button> mid-action, dropping focus the
same way the native disabled attribute the earlier commit removed did.
renderAsLink now checks isTrueDisabled only; a genuinely disabled href
Button still falls back (disabled links are an accessibility
anti-pattern), but busy-only stays an anchor with aria-busy and
aria-disabled applied directly to it, guarded by the same handleClick
ref check that already suppresses activation. Carries over and credits
@AKnassa's anchor-preservation work from facebook#4879.

Reconciled the Chromium a11y contract's own accounting: the fix turns
button.focus.reachable-and-escapable and button.unavailable.inert from
known-failure into unexpected-pass for both Button and IconButton's
loading state (verified against a real Chromium run before and after),
so removed the four now-stale known-failure records and the two
declaredNotDelivered entries they were paired with.

Updated Button and IconButton's own consumer docs (isLoading no longer
disables interaction; it blocks re-activation while staying focusable)
and ListInput's loading-state test, whose add-item button receives
isLoading directly and is now aria-disabled rather than natively
disabled while busy — its remove/reorder controls map ListInput's
loading state onto isDisabled instead and are unaffected.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for restoring keyboard focus while a Button is busy and for making a failed action retryable — the hard-disabled/busy split, docs, and edge-case tests make that contract clear.

One fix is needed before this is ready: a busy link-rendered Button still forwards Enter/Space to a consumer onKeyDown handler while it exposes aria-disabled="true". In packages/core/src/Button/Button.tsx, the link branch spreads the consumer props and overrides only onClick; the handleKeyDown guard that suppresses activation keys (and forwards other keys) is installed only on the native <button> branch. So <Button href="/docs" isLoading onKeyDown={handler} /> can run consumer keyboard side effects in a state announced as disabled, even though click activation is suppressed.

Please apply the same activation-key guard to the link branch and add a regression test showing Enter/Space do not reach consumer onKeyDown while busy, while a non-activation key still does. The busy-link tests currently cover focus and click suppression only.

[Automated review]

HelloOjasMutreja added a commit to HelloOjasMutreja/astryx that referenced this pull request Sep 24, 2026
A busy-only href Button still set renderAsLink = href != null &&
!buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a
busy link swapped to a disabled <button> mid-action, dropping focus the
same way the native disabled attribute the earlier commit removed did.
renderAsLink now checks isTrueDisabled only; a genuinely disabled href
Button still falls back (disabled links are an accessibility
anti-pattern), but busy-only stays an anchor with aria-busy and
aria-disabled applied directly to it, guarded by the same handleClick
ref check that already suppresses activation. Carries over and credits
@AKnassa's anchor-preservation work from facebook#4879.

Reconciled the Chromium a11y contract's own accounting: the fix turns
button.focus.reachable-and-escapable and button.unavailable.inert from
known-failure into unexpected-pass for both Button and IconButton's
loading state (verified against a real Chromium run before and after),
so removed the four now-stale known-failure records and the two
declaredNotDelivered entries they were paired with.

Updated Button and IconButton's own consumer docs (isLoading no longer
disables interaction; it blocks re-activation while staying focusable)
and ListInput's loading-state test, whose add-item button receives
isLoading directly and is now aria-disabled rather than natively
disabled while busy — its remove/reorder controls map ListInput's
loading state onto isDisabled instead and are unaffected.
@AKnassa

AKnassa commented Sep 26, 2026

Copy link
Copy Markdown
Contributor Author

Closing this in favour of #4885. @cixzhang picked it as the canonical fix for #4871, and it already carries the busy-href anchor preservation from here. Thanks @HelloOjasMutreja for the credit.

The one open review point on this PR also applies to #4885, so I've left a note there: a busy link-rendered Button still forwards Enter/Space to a consumer onKeyDown while it's aria-disabled.

The only piece #4885 doesn't cover is catching a rejected clickAction. On React 19 a rejected action goes to the error boundary rather than leaving the spinner stuck, so whether Button should swallow it is a contract question rather than a fix. I'm happy to open an issue if that's useful.

@AKnassa AKnassa closed this Sep 26, 2026
@github-actions
github-actions Bot deleted the rocky/issue-4871-button-busy-focus branch September 26, 2026 10:49
HelloOjasMutreja added a commit to HelloOjasMutreja/astryx that referenced this pull request Sep 26, 2026
A busy-only href Button still set renderAsLink = href != null &&
!buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a
busy link swapped to a disabled <button> mid-action, dropping focus the
same way the native disabled attribute the earlier commit removed did.
renderAsLink now checks isTrueDisabled only; a genuinely disabled href
Button still falls back (disabled links are an accessibility
anti-pattern), but busy-only stays an anchor with aria-busy and
aria-disabled applied directly to it, guarded by the same handleClick
ref check that already suppresses activation. Carries over and credits
@AKnassa's anchor-preservation work from facebook#4879.

Reconciled the Chromium a11y contract's own accounting: the fix turns
button.focus.reachable-and-escapable and button.unavailable.inert from
known-failure into unexpected-pass for both Button and IconButton's
loading state (verified against a real Chromium run before and after),
so removed the four now-stale known-failure records and the two
declaredNotDelivered entries they were paired with.

Updated Button and IconButton's own consumer docs (isLoading no longer
disables interaction; it blocks re-activation while staying focusable)
and ListInput's loading-state test, whose add-item button receives
isLoading directly and is now aria-disabled rather than natively
disabled while busy — its remove/reorder controls map ListInput's
loading state onto isDisabled instead and are unaffected.
HelloOjasMutreja added a commit to HelloOjasMutreja/astryx that referenced this pull request Sep 28, 2026
A busy-only href Button still set renderAsLink = href != null &&
!buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a
busy link swapped to a disabled <button> mid-action, dropping focus the
same way the native disabled attribute the earlier commit removed did.
renderAsLink now checks isTrueDisabled only; a genuinely disabled href
Button still falls back (disabled links are an accessibility
anti-pattern), but busy-only stays an anchor with aria-busy and
aria-disabled applied directly to it, guarded by the same handleClick
ref check that already suppresses activation. Carries over and credits
@AKnassa's anchor-preservation work from facebook#4879.

Reconciled the Chromium a11y contract's own accounting: the fix turns
button.focus.reachable-and-escapable and button.unavailable.inert from
known-failure into unexpected-pass for both Button and IconButton's
loading state (verified against a real Chromium run before and after),
so removed the four now-stale known-failure records and the two
declaredNotDelivered entries they were paired with.

Updated Button and IconButton's own consumer docs (isLoading no longer
disables interaction; it blocks re-activation while staying focusable)
and ListInput's loading-state test, whose add-item button receives
isLoading directly and is now aria-disabled rather than natively
disabled while busy — its remove/reorder controls map ListInput's
loading state onto isDisabled instead and are unaffected.
HelloOjasMutreja added a commit to HelloOjasMutreja/astryx that referenced this pull request Oct 10, 2026
A busy-only href Button still set renderAsLink = href != null &&
!buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a
busy link swapped to a disabled <button> mid-action, dropping focus the
same way the native disabled attribute the earlier commit removed did.
renderAsLink now checks isTrueDisabled only; a genuinely disabled href
Button still falls back (disabled links are an accessibility
anti-pattern), but busy-only stays an anchor with aria-busy and
aria-disabled applied directly to it, guarded by the same handleClick
ref check that already suppresses activation. Carries over and credits
@AKnassa's anchor-preservation work from facebook#4879.

Reconciled the Chromium a11y contract's own accounting: the fix turns
button.focus.reachable-and-escapable and button.unavailable.inert from
known-failure into unexpected-pass for both Button and IconButton's
loading state (verified against a real Chromium run before and after),
so removed the four now-stale known-failure records and the two
declaredNotDelivered entries they were paired with.

Updated Button and IconButton's own consumer docs (isLoading no longer
disables interaction; it blocks re-activation while staying focusable)
and ListInput's loading-state test, whose add-item button receives
isLoading directly and is now aria-disabled rather than natively
disabled while busy — its remove/reorder controls map ListInput's
loading state onto isDisabled instead and are unaffected.
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. community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Button uses native disabled for busy state, which drops focus

1 participant