Skip to content

Commit f8ec772

Browse files
committed
Fix html.button staying unclickable on Android after toggling disabled back to false (#491)
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`): ```js 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: ```js // 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: ```js 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.
1 parent c4e69e0 commit f8ec772

4 files changed

Lines changed: 67 additions & 2 deletions

File tree

packages/react-strict-dom/src/native/modules/createStrictDOMComponent.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,13 @@ export function createStrictDOMComponent<T, P extends StrictProps>(
9090
// Component-specific props
9191

9292
if (NativeComponent === ReactNative.Pressable) {
93+
// Always pass an explicit boolean so that toggling 'disabled' resets the
94+
// native view's enabled state. Passing 'undefined' when re-enabling leaves
95+
// 'accessibilityState.disabled' unset, which on Android keeps the view's
96+
// native 'enabled' state false and makes the Pressable unclickable.
97+
// $FlowFixMe[react-rule-hook-mutation]
98+
nativeProps.disabled = props.disabled === true;
9399
if (props.disabled === true) {
94-
// $FlowFixMe[react-rule-hook-mutation]
95-
nativeProps.disabled = true;
96100
// $FlowFixMe[react-rule-hook-mutation]
97101
nativeProps.focusable = false;
98102
}

packages/react-strict-dom/tests/html/__snapshots__/html-test.js.snap-native

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ exports[`<html.*> "article" supports global attributes 1`] = `
227227

228228
exports[`<html.*> "article" supports inline event handlers 1`] = `
229229
<Pressable
230+
disabled={false}
230231
onBlur={[Function]}
231232
onFocus={[Function]}
232233
onGotPointerCapture={[Function]}
@@ -345,6 +346,7 @@ exports[`<html.*> "aside" supports global attributes 1`] = `
345346

346347
exports[`<html.*> "aside" supports inline event handlers 1`] = `
347348
<Pressable
349+
disabled={false}
348350
onBlur={[Function]}
349351
onFocus={[Function]}
350352
onGotPointerCapture={[Function]}
@@ -812,6 +814,7 @@ exports[`<html.*> "blockquote" supports global attributes 1`] = `
812814

813815
exports[`<html.*> "blockquote" supports inline event handlers 1`] = `
814816
<Pressable
817+
disabled={false}
815818
onBlur={[Function]}
816819
onFocus={[Function]}
817820
onGotPointerCapture={[Function]}
@@ -965,6 +968,7 @@ exports[`<html.*> "br" supports inline event handlers 1`] = `
965968

966969
exports[`<html.*> "button" default rendering 1`] = `
967970
<Pressable
971+
disabled={false}
968972
ref={[Function]}
969973
role="button"
970974
style={
@@ -979,6 +983,7 @@ exports[`<html.*> "button" default rendering 1`] = `
979983

980984
exports[`<html.*> "button" ignores and warns about unsupported attributes 1`] = `
981985
<Pressable
986+
disabled={false}
982987
ref={[Function]}
983988
role="button"
984989
style={
@@ -1037,6 +1042,7 @@ exports[`<html.*> "button" supports global attributes 1`] = `
10371042
}
10381043
}
10391044
accessibilityViewIsModal={true}
1045+
disabled={false}
10401046
focusable={true}
10411047
importantForAccessibility="no-hide-descendants"
10421048
nativeID="some-id"
@@ -1068,6 +1074,7 @@ exports[`<html.*> "button" supports global attributes 1`] = `
10681074

10691075
exports[`<html.*> "button" supports inline event handlers 1`] = `
10701076
<Pressable
1077+
disabled={false}
10711078
onBlur={[Function]}
10721079
onFocus={[Function]}
10731080
onGotPointerCapture={[Function]}
@@ -1426,6 +1433,7 @@ exports[`<html.*> "div" supports global attributes 1`] = `
14261433

14271434
exports[`<html.*> "div" supports inline event handlers 1`] = `
14281435
<Pressable
1436+
disabled={false}
14291437
onBlur={[Function]}
14301438
onFocus={[Function]}
14311439
onGotPointerCapture={[Function]}
@@ -1663,6 +1671,7 @@ exports[`<html.*> "fieldset" supports global attributes 1`] = `
16631671

16641672
exports[`<html.*> "fieldset" supports inline event handlers 1`] = `
16651673
<Pressable
1674+
disabled={false}
16661675
onBlur={[Function]}
16671676
onFocus={[Function]}
16681677
onGotPointerCapture={[Function]}
@@ -1781,6 +1790,7 @@ exports[`<html.*> "footer" supports global attributes 1`] = `
17811790

17821791
exports[`<html.*> "footer" supports inline event handlers 1`] = `
17831792
<Pressable
1793+
disabled={false}
17841794
onBlur={[Function]}
17851795
onFocus={[Function]}
17861796
onGotPointerCapture={[Function]}
@@ -1899,6 +1909,7 @@ exports[`<html.*> "form" supports global attributes 1`] = `
18991909

19001910
exports[`<html.*> "form" supports inline event handlers 1`] = `
19011911
<Pressable
1912+
disabled={false}
19021913
onBlur={[Function]}
19031914
onFocus={[Function]}
19041915
onGotPointerCapture={[Function]}
@@ -2775,6 +2786,7 @@ exports[`<html.*> "header" supports global attributes 1`] = `
27752786

27762787
exports[`<html.*> "header" supports inline event handlers 1`] = `
27772788
<Pressable
2789+
disabled={false}
27782790
onBlur={[Function]}
27792791
onFocus={[Function]}
27802792
onGotPointerCapture={[Function]}
@@ -2900,6 +2912,7 @@ exports[`<html.*> "hr" supports global attributes 1`] = `
29002912

29012913
exports[`<html.*> "hr" supports inline event handlers 1`] = `
29022914
<Pressable
2915+
disabled={false}
29032916
onBlur={[Function]}
29042917
onFocus={[Function]}
29052918
onGotPointerCapture={[Function]}
@@ -3805,6 +3818,7 @@ exports[`<html.*> "main" supports global attributes 1`] = `
38053818

38063819
exports[`<html.*> "main" supports inline event handlers 1`] = `
38073820
<Pressable
3821+
disabled={false}
38083822
onBlur={[Function]}
38093823
onFocus={[Function]}
38103824
onGotPointerCapture={[Function]}
@@ -4046,6 +4060,7 @@ exports[`<html.*> "nav" supports global attributes 1`] = `
40464060

40474061
exports[`<html.*> "nav" supports inline event handlers 1`] = `
40484062
<Pressable
4063+
disabled={false}
40494064
onBlur={[Function]}
40504065
onFocus={[Function]}
40514066
onGotPointerCapture={[Function]}
@@ -4166,6 +4181,7 @@ exports[`<html.*> "ol" supports global attributes 1`] = `
41664181

41674182
exports[`<html.*> "ol" supports inline event handlers 1`] = `
41684183
<Pressable
4184+
disabled={false}
41694185
onBlur={[Function]}
41704186
onFocus={[Function]}
41714187
onGotPointerCapture={[Function]}
@@ -4298,6 +4314,7 @@ exports[`<html.*> "optgroup" supports global attributes 1`] = `
42984314

42994315
exports[`<html.*> "optgroup" supports inline event handlers 1`] = `
43004316
<Pressable
4317+
disabled={false}
43014318
onBlur={[Function]}
43024319
onFocus={[Function]}
43034320
onGotPointerCapture={[Function]}
@@ -4897,6 +4914,7 @@ exports[`<html.*> "section" supports global attributes 1`] = `
48974914

48984915
exports[`<html.*> "section" supports inline event handlers 1`] = `
48994916
<Pressable
4917+
disabled={false}
49004918
onBlur={[Function]}
49014919
onFocus={[Function]}
49024920
onGotPointerCapture={[Function]}
@@ -5027,6 +5045,7 @@ exports[`<html.*> "select" supports global attributes 1`] = `
50275045

50285046
exports[`<html.*> "select" supports inline event handlers 1`] = `
50295047
<Pressable
5048+
disabled={false}
50305049
onBlur={[Function]}
50315050
onFocus={[Function]}
50325051
onGotPointerCapture={[Function]}
@@ -5888,6 +5907,7 @@ exports[`<html.*> "ul" supports global attributes 1`] = `
58885907

58895908
exports[`<html.*> "ul" supports inline event handlers 1`] = `
58905909
<Pressable
5910+
disabled={false}
58915911
onBlur={[Function]}
58925912
onFocus={[Function]}
58935913
onGotPointerCapture={[Function]}

packages/react-strict-dom/tests/html/__snapshots__/html-test.native.js.snap

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,22 @@ exports[`<html.*> (native polyfills) polyfills: layout default flex layout: flex
216216
</ViewNativeComponent>
217217
`;
218218

219+
exports[`<html.*> (native polyfills) polyfills: props <button> "disabled" prop 1`] = `
220+
<Pressable
221+
disabled={true}
222+
focusable={false}
223+
ref={[Function]}
224+
role="button"
225+
style={
226+
{
227+
"borderWidth": 1,
228+
"boxSizing": "content-box",
229+
"position": "static",
230+
}
231+
}
232+
/>
233+
`;
234+
219235
exports[`<html.*> (native polyfills) polyfills: props <img> "src" prop with loading props 1`] = `
220236
<Image
221237
crossOrigin="use-credentials"

packages/react-strict-dom/tests/html/html-test.native.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,31 @@ describe('<html.*> (native polyfills)', () => {
500500
expect(root.toJSON()).toMatchSnapshot();
501501
});
502502
});
503+
504+
describe('<button>', () => {
505+
test('"disabled" prop', () => {
506+
let root;
507+
act(() => {
508+
root = create(<html.button disabled={true} />);
509+
});
510+
expect(root.toJSON()).toMatchSnapshot();
511+
});
512+
513+
// Re-enabling must emit an explicit "disabled={false}" rather than
514+
// dropping the prop. On Android, leaving "disabled" unset keeps the
515+
// native view's enabled state false and the button stays unclickable.
516+
test('"disabled" prop resets to an explicit false when re-enabled', () => {
517+
let root;
518+
act(() => {
519+
root = create(<html.button disabled={true} />);
520+
});
521+
expect(root.toJSON().props.disabled).toBe(true);
522+
act(() => {
523+
root.update(<html.button disabled={false} />);
524+
});
525+
expect(root.toJSON().props.disabled).toBe(false);
526+
});
527+
});
503528
});
504529

505530
describe('polyfills: inheritence', () => {

0 commit comments

Comments
 (0)