Skip to content

Fix html.button staying unclickable on Android after toggling disabled back to false (#491)#492

Open
MoOx wants to merge 1 commit into
react:mainfrom
MoOx:button-disabled-fix-491
Open

Fix html.button staying unclickable on Android after toggling disabled back to false (#491)#492
MoOx wants to merge 1 commit into
react:mainfrom
MoOx:button-disabled-fix-491

Conversation

@MoOx

@MoOx MoOx commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Closes #491

Problem

On Android, when an html.button (or any RSD element rendered as a Pressable) has its disabled state set to true and then back to false, the pressable's own surface remains unclickable — only its children continue to trigger the press handler. On iOS the button recovers correctly.

This does not reproduce with a plain React Native <Pressable disabled={...} />, and the usual key-based remount workaround "fixes" it — both signs that the regression is in how RSD forwards the prop, not in React Native itself.

Root cause

In createStrictDOMComponent.js, the disabled prop was forwarded to the underlying Pressable only when it was true, and never reset when it became false (disabled is intentionally not passed through useNativeProps):

if (NativeComponent === ReactNative.Pressable) {
  if (props.disabled === true) {
    nativeProps.disabled = true;
    nativeProps.focusable = false;
  }
}

So Pressable saw disabled toggle true → undefined, never true → false. React Native's Pressable only merges disabled into its accessibilityState when it's non-null:

// react-native/Libraries/Components/Pressable/Pressable.js
_accessibilityState =
  disabled != null ? {..._accessibilityState, disabled} : _accessibilityState;

With undefined, disabled drops out, so accessibilityState.disabled goes true → undefined instead of true → false. On Android, accessibilityState.disabled = true sets the native view to enabled = false; reverting to undefined (rather than an explicit false) does not re-enable it. A disabled Android ViewGroup still dispatches touches to its children (so the inner text kept working) but won't claim touches on its own surface — exactly the reported symptom. iOS has no equivalent native "enabled" latch, so it recovered regardless.

Plain RN Pressable works because the user passes an explicit boolean that always resets to false.

Fix

Always forward disabled to the Pressable as an explicit boolean, so it transitions true ↔ false and Android correctly resets the native enabled state:

if (NativeComponent === ReactNative.Pressable) {
  // Always pass an explicit boolean so that toggling 'disabled' resets the
  // native view's enabled state. Passing 'undefined' when re-enabling leaves
  // 'accessibilityState.disabled' unset, which on Android keeps the view's
  // native 'enabled' state false and makes the Pressable unclickable.
  nativeProps.disabled = props.disabled === true;
  if (props.disabled === true) {
    nativeProps.focusable = false;
  }
}

Note on focusable (intentionally left conditional)

focusable does not need the same symmetric treatment, for two reasons:

  1. It never reaches native as undefined. Pressable normalizes it to an explicit boolean on every render via focusable: focusable !== false. So when RSD leaves it unset on re-enable, the native View receives focusable={true}; when disabled it receives focusable={false}. The value always toggles false ↔ true at the native layer, so there's no stale-state problem like disabled had.

  2. Making it unconditional would regress tabIndex. focusable is also derived from tabIndex in useNativeProps (nativeProps.focusable = !tabIndex). The current code only forces focusable = false while disabled and otherwise preserves the tabIndex-derived value. Changing it to something like focusable = props.disabled !== true would clobber tabIndex (e.g. an enabled button with tabIndex={-1} would wrongly become focusable).

So the disabled fix alone is sufficient, and the asymmetric focusable handling is correct and intentional.

Tests

  • Added a <button> regression test asserting that after toggling disabled from true to false, the rendered Pressable emits an explicit disabled={false} (rather than dropping the prop).
  • Full native test suite passes; Flow is clean.

Alternative: fixing this without emitting disabled={false} on every Pressable

The current fix always forwards an explicit boolean:

nativeProps.disabled = props.disabled === true;

This is the most defensive option — it even survives a consumer toggling disabled between true and undefined — but it has a cost: every element that renders as a Pressable (every html.button, plus any element upgraded to a Pressable because it has an event handler) now carries a disabled={false} prop. That's why ~20 native snapshots changed even though their behaviour didn't.

Preferred alternative — only forward disabled when it is explicitly provided

if (NativeComponent === ReactNative.Pressable) {
  if (props.disabled != null) {
    // forward the consumer's value as-is (true *or* false)
    nativeProps.disabled = props.disabled;
  }
  if (props.disabled === true) {
    nativeProps.focusable = false;
  }
}

Why this avoids the snapshot churn:

  • An element that never sets disabled (the vast majority — the default button render, event-handler views, etc.) emits no disabled prop at all, exactly as before → those snapshots stay unchanged.
  • The moment a consumer controls disabled with state (disabled={buttonDisabled}, as in the repro), the value is a real boolean on both transitions, so the Pressable receives disabled={true}disabled={false}. That's precisely what resets Android's native enabled state and fixes the bug.

So the only snapshots that would change are ones that explicitly pass disabled={false} (there are essentially none today), instead of all of them.

Trade-off to be aware of

This version relies on the consumer passing an explicit boolean in both directions — which is the idiomatic controlled pattern and matches HTML's boolean-attribute semantics. It would not rescue an unusual pattern like disabled={cond ? true : undefined}, because then disabled is null/absent on the "enabled" render and we'd skip forwarding it (re-introducing the true → undefined transition that Android doesn't reset). The === true version in this PR is immune to that at the price of the snapshot noise.

If we'd rather keep the snapshots clean, I'm happy to switch to the != null form — it covers the reported case and every realistic controlled usage.

Root cause is arguably upstream

The underlying issue is that on Android, React Native does not reset the native view's enabled flag when accessibilityState.disabled goes true → undefined (only true → false resets it). A defensive fix in RSD makes sense regardless, but it may be worth reporting/fixing in React Native so undefined and false behave consistently across platforms.

Copilot AI review requested due to automatic review settings June 1, 2026 13:55
@meta-cla meta-cla Bot added the cla signed label Jun 1, 2026

Copilot AI 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.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR addresses a React Native (Android) issue where toggling disabled on Pressable-backed strict DOM components can leave the view stuck in a disabled/unclickable state by ensuring disabled is always passed as an explicit boolean.

Changes:

  • Always set nativeProps.disabled to a boolean for ReactNative.Pressable components.
  • Add native tests covering <html.button disabled> and re-enabling behavior.
  • Update/extend native snapshots to reflect explicit disabled={false} being passed.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
packages/react-strict-dom/tests/html/html-test.native.js Adds coverage for <button> disabled behavior and re-enable semantics in native polyfills tests.
packages/react-strict-dom/tests/html/snapshots/html-test.native.js.snap Adds snapshot for <button> with disabled={true} in native polyfills.
packages/react-strict-dom/tests/html/snapshots/html-test.js.snap-native Updates many snapshots to include explicit disabled={false} on Pressable.
packages/react-strict-dom/src/native/modules/createStrictDOMComponent.js Implements the behavioral fix by always emitting a boolean disabled prop for Pressable.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +93 to +98
// Always pass an explicit boolean so that toggling 'disabled' resets the
// native view's enabled state. Passing 'undefined' when re-enabling leaves
// 'accessibilityState.disabled' unset, which on Android keeps the view's
// native 'enabled' state false and makes the Pressable unclickable.
// $FlowFixMe[react-rule-hook-mutation]
nativeProps.disabled = props.disabled === true;
Comment on lines +95 to +96
// 'accessibilityState.disabled' unset, which on Android keeps the view's
// native 'enabled' state false and makes the Pressable unclickable.
Comment on lines +513 to +526
// Re-enabling must emit an explicit "disabled={false}" rather than
// dropping the prop. On Android, leaving "disabled" unset keeps the
// native view's enabled state false and the button stays unclickable.
test('"disabled" prop resets to an explicit false when re-enabled', () => {
let root;
act(() => {
root = create(<html.button disabled={true} />);
});
expect(root.toJSON().props.disabled).toBe(true);
act(() => {
root.update(<html.button disabled={false} />);
});
expect(root.toJSON().props.disabled).toBe(false);
});
@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown

workflow: benchmarks/perf (native)

Comparison of performance test results, measured in operations per second. Larger is better.

Results Base Patch Ratio
css.create
· small 1,124,511 1,127,710 1.00 +
· small with units 503,774 503,213 1.00 -
· small with variables 671,462 676,407 1.01 +
· several small 353,771 355,852 1.01 +
· large 199,368 200,798 1.01 +
· large with polyfills 150,366 149,571 0.99 -
· complex 103,050 103,694 1.01 +
· unsupported 211,361 210,377 1.00 -
css.createTheme
· simple theme 222,637 223,037 1.00 +
· polyfill theme 211,362 212,102 1.00 +

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown

workflow: benchmarks/size

Comparison of minified (terser) and compressed (brotli) size results, measured in bytes. Smaller is better.

Results Base Patch Ratio
react-strict-dom/dist/web/index.js
· compressed 3,251 3,251 1.00
· minified 10,375 10,375 1.00
react-strict-dom/dist/web/runtime.js
· compressed 1,645 1,645 1.00
· minified 4,131 4,131 1.00
react-strict-dom/dist/native/index.js
· compressed 16,618 16,656 1.00 +
· minified 64,626 64,660 1.00 +
react-strict-animated/dist/web/index.js
· compressed 6,861 6,861 1.00
· minified 23,486 23,486 1.00
react-strict-animated/dist/native/index.js
· compressed 797 797 1.00
· minified 2,518 2,518 1.00

// 'accessibilityState.disabled' unset, which on Android keeps the view's
// native 'enabled' state false and makes the Pressable unclickable.
// $FlowFixMe[react-rule-hook-mutation]
nativeProps.disabled = props.disabled === true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'm wondering if it wouldn't be better to implement it as:

if (props.disabled === true) {
  nativeProps.disabled = true;
  ...
} else if (props.disabled === false) {
  nativeProps.disabled = false;
}

Because disabled prop can be either:

  • false
  • true
  • undefined

And explicitly passing boolean value (false) in case it's undefined seems to affect e.g. accessibility state of the component (you can check it e.g. by viewing test snapshots). So as this issue only seems to happen when disabled value changes, it'd probably be better to narrow fix down to those cases and to leave undefined state as it was 🤔

…d back to false (react#491)

## Problem

On Android, when an html.button (or any RSD element rendered as a
Pressable) has its disabled state set to true and then back to false, the
pressable's own surface remains unclickable — only its children continue
to trigger the press handler. On iOS the button recovers correctly.

This does not reproduce with a plain React Native <Pressable disabled={...} />,
and the usual key-based remount workaround "fixes" it — both signs that the
regression is in how RSD forwards the prop, not in React Native itself.

## Root cause

In createStrictDOMComponent.js, the disabled prop was forwarded to the
underlying Pressable only when it was true, and never reset when it became
false (disabled is intentionally not passed through useNativeProps):

    if (NativeComponent === ReactNative.Pressable) {
      if (props.disabled === true) {
        nativeProps.disabled = true;
        nativeProps.focusable = false;
      }
    }

So Pressable saw disabled toggle true -> undefined, never true -> false.
React Native's Pressable only merges disabled into its accessibilityState
when it's non-null:

    // react-native/Libraries/Components/Pressable/Pressable.js
    _accessibilityState =
      disabled != null ? {..._accessibilityState, disabled} : _accessibilityState;

With undefined, disabled drops out, so accessibilityState.disabled goes
true -> undefined instead of true -> false. On Android,
accessibilityState.disabled = true sets the native view to enabled = false;
reverting to undefined (rather than an explicit false) does not re-enable it.
A disabled Android ViewGroup still dispatches touches to its children (so the
inner text kept working) but won't claim touches on its own surface — exactly
the reported symptom. iOS has no equivalent native "enabled" latch, so it
recovered regardless.

Plain RN Pressable works because the user passes an explicit boolean that
always resets to false.

## Fix

Forward disabled to the Pressable when it is an explicit boolean, so toggling
true <-> false resets the native enabled state:

    if (props.disabled === true) {
      nativeProps.disabled = true;
      nativeProps.focusable = false;
    } else if (props.disabled === false) {
      nativeProps.disabled = false;
    }

We only emit the prop for an explicit boolean (true or false), leaving the
undefined case untouched. This keeps the blast radius minimal: elements that
never opt into disabled keep their original accessibility state (no
accessibilityState.disabled = false forced on every Pressable), and no
existing snapshots change. The trade-off is that the true -> undefined
transition is intentionally not covered, but a controlled disabled is
practically always bound to a boolean and toggles true <-> false.

## Note on focusable (intentionally left conditional)

focusable does not need the same treatment, for two reasons:

1. It never reaches native as undefined. Pressable normalizes it to an
   explicit boolean on every render via `focusable: focusable !== false`. So
   when RSD leaves it unset on re-enable, the native View receives
   focusable={true}; when disabled it receives focusable={false}. The value
   always toggles false <-> true at the native layer, so there's no
   stale-state problem like disabled had.

2. Making it unconditional would regress tabIndex. focusable is also derived
   from tabIndex in useNativeProps (nativeProps.focusable = !tabIndex). The
   current code only forces focusable = false while disabled and otherwise
   preserves the tabIndex-derived value.

## On the RN version sensitivity (0.83 OK, 0.85 KO)

The RSD code is identical across RN versions, so the prop sequence we send
(disabled: true -> undefined) does not depend on the version — the latent
asymmetry has always been there. What changed between 0.83 and 0.85 is RN's
Android native behaviour: whether the view's native enabled flag is reset when
accessibilityState.disabled goes true -> undefined (vs true -> false). The fix
is version-agnostic since it makes the transition an explicit true <-> false.

## Tests

- Added a <button> regression test asserting that after toggling disabled from
  true to false, the rendered Pressable emits an explicit disabled={false}
  (rather than dropping the prop).
- Full test suite passes with no snapshot changes; Flow is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@MoOx MoOx force-pushed the button-disabled-fix-491 branch from f8ec772 to 135f6d4 Compare June 1, 2026 18:46
@MoOx

MoOx commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Good point — I've updated the PR to narrow it to the explicit-boolean cases:

if (props.disabled === true) {
  nativeProps.disabled = true;
  nativeProps.focusable = false;
} else if (props.disabled === false) {
  nativeProps.disabled = false;
}

This fixes the reported issue (a controlled disabled toggles true ↔ false, and both go through explicit branches that reset the native state) while avoiding a behavioural change for elements that never opt into disabled. The accessibility argument is the clincher: forcing accessibilityState.disabled = false on every Pressable (including plain views with an onClick) changes their a11y semantics unnecessarily — and that's exactly what was showing up as snapshot churn. Keeping undefined untouched is the smaller blast radius and the more faithful polyfill behaviour, and no existing snapshots change.

The one case this consciously doesn't cover is toggling true → undefined directly (e.g. disabled={cond || undefined} or a conditional spread), which would re-introduce the original true → undefined transition. That's a non-idiomatic pattern for a controlled disabled (which is almost always bound to a boolean), so it seems like a fine trade-off — I added a code comment documenting it so it's an explicit decision rather than an accidental gap.

For context on the RN-version sensitivity you noticed (0.83 OK, 0.85 KO): the RSD code is identical across RN versions, so the prop sequence we send (disabled: true → undefined) doesn't depend on the version — the latent asymmetry has always been there. What changed between 0.83 and 0.85 is purely RN's Android native side: whether the view's native enabled flag is reset when accessibilityState.disabled goes true → undefined (vs true → false). New Arch has been the only path since 0.82, so this is a refinement inside Fabric / the Android view manager rather than an arch switch. That also explains why this repo (pinned to 0.83.6) never caught it in CI. The fix is version-agnostic since it makes the transition an explicit true ↔ false.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Button remains unclickable after disabled state changes

3 participants