Fix html.button staying unclickable on Android after toggling disabled back to false (#491)#492
Fix html.button staying unclickable on Android after toggling disabled back to false (#491)#492MoOx wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
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.disabledto a boolean forReactNative.Pressablecomponents. - 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.
| // 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; |
| // 'accessibilityState.disabled' unset, which on Android keeps the view's | ||
| // native 'enabled' state false and makes the Pressable unclickable. |
| // 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); | ||
| }); |
workflow: benchmarks/perf (native)Comparison of performance test results, measured in operations per second. Larger is better.
|
workflow: benchmarks/sizeComparison of minified (terser) and compressed (brotli) size results, measured in bytes. Smaller is better.
|
| // '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; |
There was a problem hiding this comment.
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:
falsetrueundefined
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>
f8ec772 to
135f6d4
Compare
|
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 The one case this consciously doesn't cover is toggling 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 ( |
Closes #491
Problem
On Android, when an
html.button(or any RSD element rendered as aPressable) has itsdisabledstate set totrueand then back tofalse, 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 usualkey-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, thedisabledprop was forwarded to the underlyingPressableonly when it wastrue, and never reset when it becamefalse(disabledis intentionally not passed throughuseNativeProps):So
Pressablesawdisabledtoggletrue → undefined, nevertrue → false. React Native'sPressableonly mergesdisabledinto itsaccessibilityStatewhen it's non-null:With
undefined,disableddrops out, soaccessibilityState.disabledgoestrue → undefinedinstead oftrue → false. On Android,accessibilityState.disabled = truesets the native view toenabled = false; reverting toundefined(rather than an explicitfalse) does not re-enable it. A disabled AndroidViewGroupstill 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
Pressableworks because the user passes an explicit boolean that always resets tofalse.Fix
Always forward
disabledto thePressableas an explicit boolean, so it transitionstrue ↔ falseand Android correctly resets the native enabled state:Note on
focusable(intentionally left conditional)focusabledoes not need the same symmetric treatment, for two reasons:It never reaches native as
undefined.Pressablenormalizes it to an explicit boolean on every render viafocusable: focusable !== false. So when RSD leaves it unset on re-enable, the native View receivesfocusable={true}; when disabled it receivesfocusable={false}. The value always togglesfalse ↔ trueat the native layer, so there's no stale-state problem likedisabledhad.Making it unconditional would regress
tabIndex.focusableis also derived fromtabIndexinuseNativeProps(nativeProps.focusable = !tabIndex). The current code only forcesfocusable = falsewhile disabled and otherwise preserves thetabIndex-derived value. Changing it to something likefocusable = props.disabled !== truewould clobbertabIndex(e.g. an enabled button withtabIndex={-1}would wrongly become focusable).So the
disabledfix alone is sufficient, and the asymmetricfocusablehandling is correct and intentional.Tests
<button>regression test asserting that after togglingdisabledfromtruetofalse, the renderedPressableemits an explicitdisabled={false}(rather than dropping the prop).Alternative: fixing this without emitting
disabled={false}on everyPressableThe current fix always forwards an explicit boolean:
This is the most defensive option — it even survives a consumer toggling
disabledbetweentrueandundefined— but it has a cost: every element that renders as aPressable(everyhtml.button, plus any element upgraded to aPressablebecause it has an event handler) now carries adisabled={false}prop. That's why ~20 native snapshots changed even though their behaviour didn't.Preferred alternative — only forward
disabledwhen it is explicitly providedWhy this avoids the snapshot churn:
disabled(the vast majority — the default button render, event-handler views, etc.) emits nodisabledprop at all, exactly as before → those snapshots stay unchanged.disabledwith state (disabled={buttonDisabled}, as in the repro), the value is a real boolean on both transitions, so thePressablereceivesdisabled={true}↔disabled={false}. That's precisely what resets Android's nativeenabledstate 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 thendisabledisnull/absent on the "enabled" render and we'd skip forwarding it (re-introducing thetrue → undefinedtransition that Android doesn't reset). The=== trueversion 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
!= nullform — 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
enabledflag whenaccessibilityState.disabledgoestrue → undefined(onlytrue → falseresets it). A defensive fix in RSD makes sense regardless, but it may be worth reporting/fixing in React Native soundefinedandfalsebehave consistently across platforms.