From 135f6d4fcf43d6a621933a4d1fb868aefe6220c7 Mon Sep 17 00:00:00 2001 From: Max Thirouin Date: Mon, 1 Jun 2026 15:43:27 +0200 Subject: [PATCH] Fix html.button staying unclickable on Android after toggling disabled back to false (#491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 , 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