From 8802415dd55c9b42b9e69cc3136d6014e9275a88 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 26 Mar 2026 17:03:19 -0500 Subject: [PATCH 1/4] Add JS shim for legacy validKeys props --- .../Components/Pressable/Pressable.js | 6 +- .../TextInput/RCTTextInputViewConfig.js | 16 +++++ .../Components/TextInput/TextInput.js | 48 +++++++++++---- .../Libraries/Components/View/View.js | 59 +++++++++++++++---- .../normalizeLegacyHandledKeyEvents.js | 57 ++++++++++++++++++ 5 files changed, 162 insertions(+), 24 deletions(-) create mode 100644 packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js diff --git a/packages/react-native/Libraries/Components/Pressable/Pressable.js b/packages/react-native/Libraries/Components/Pressable/Pressable.js index ece22fb83dcc..e9d436100af1 100644 --- a/packages/react-native/Libraries/Components/Pressable/Pressable.js +++ b/packages/react-native/Libraries/Components/Pressable/Pressable.js @@ -381,7 +381,11 @@ function Pressable({ // [macOS acceptsFirstMouse: acceptsFirstMouse !== false && !disabled, enableFocusRing: enableFocusRing !== false && !disabled, - keyDownEvents: keyDownEvents ?? [{key: ' '}, {key: 'Enter'}], + keyDownEvents: + keyDownEvents ?? + (((props: any).validKeysDown: mixed) == null + ? [{key: ' '}, {key: 'Enter'}] + : undefined), mouseDownCanMoveWindow: false, // macOS] }; diff --git a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js index 1c24dfa93f82..253c2a49e4ed 100644 --- a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js +++ b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js @@ -52,6 +52,18 @@ const RCTTextInputViewConfig: PartialViewConfigWithoutName = { captured: 'onSubmitEditingCapture', }, }, + topKeyDown: { + phasedRegistrationNames: { + bubbled: 'onKeyDown', + captured: 'onKeyDownCapture', + }, + }, + topKeyUp: { + phasedRegistrationNames: { + bubbled: 'onKeyUp', + captured: 'onKeyUpCapture', + }, + }, topTouchCancel: { phasedRegistrationNames: { bubbled: 'onTouchCancel', @@ -173,6 +185,8 @@ const RCTTextInputViewConfig: PartialViewConfigWithoutName = { clearTextOnSubmit: true, grammarCheck: true, hideVerticalScrollIndicator: true, + keyDownEvents: true, + keyUpEvents: true, pastedTypes: true, submitKeyEvents: true, tooltip: true, @@ -191,6 +205,8 @@ const RCTTextInputViewConfig: PartialViewConfigWithoutName = { onAutoCorrectChange: true, onSpellCheckChange: true, onGrammarCheckChange: true, + onKeyDown: true, + onKeyUp: true, // macOS] }), disableKeyboardShortcuts: true, diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 610d16504866..35da07c4cabb 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -61,6 +61,9 @@ import StyleSheet, {type TextStyleProp} from '../../StyleSheet/StyleSheet'; import Text from '../../Text/Text'; import TextAncestorContext from '../../Text/TextAncestorContext'; import Platform from '../../Utilities/Platform'; +import normalizeLegacyHandledKeyEvents, { + type LegacyHandledKeyEvent, +} from '../../Utilities/normalizeLegacyHandledKeyEvents'; import useMergeRefs from '../../Utilities/useMergeRefs'; import TextInputState from './TextInputState'; import invariant from 'invariant'; @@ -386,6 +389,13 @@ function useTextInputStateSynchronization({ * */ function InternalTextInput(props: TextInputProps): React.Node { + const validKeysDown = + ((props: any).validKeysDown: ?$ReadOnlyArray); + const validKeysUp = + ((props: any).validKeysUp: ?$ReadOnlyArray); + const propsWithoutLegacyKeyProps = ({...props}: any); + delete propsWithoutLegacyKeyProps.validKeysDown; + delete propsWithoutLegacyKeyProps.validKeysUp; const { 'aria-busy': ariaBusy, 'aria-checked': ariaChecked, @@ -400,7 +410,17 @@ function InternalTextInput(props: TextInputProps): React.Node { selectionHandleColor, cursorColor, ...otherProps - } = props; + } = propsWithoutLegacyKeyProps; + const normalizedKeyDownEvents = + propsWithoutLegacyKeyProps.keyDownEvents ?? + normalizeLegacyHandledKeyEvents(validKeysDown); + const normalizedKeyUpEvents = + propsWithoutLegacyKeyProps.keyUpEvents ?? + normalizeLegacyHandledKeyEvents(validKeysUp); + const isUsingLegacyKeyDownProp = + propsWithoutLegacyKeyProps.keyDownEvents == null && validKeysDown != null; + const isUsingLegacyKeyUpProp = + propsWithoutLegacyKeyProps.keyUpEvents == null && validKeysUp != null; const inputRef = useRef(null); @@ -582,9 +602,9 @@ function InternalTextInput(props: TextInputProps): React.Node { // [macOS const _onKeyDown = (event: KeyEvent) => { - const keyDownEvents = props.keyDownEvents; - if (keyDownEvents != null && !event.isPropagationStopped()) { - const isHandled = keyDownEvents.some( + let isHandled = false; + if (normalizedKeyDownEvents != null && !event.isPropagationStopped()) { + isHandled = normalizedKeyDownEvents.some( ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { return ( event.nativeEvent.key === key && @@ -595,17 +615,19 @@ function InternalTextInput(props: TextInputProps): React.Node { ); }, ); - if (isHandled === true) { + if (isHandled === true && !isUsingLegacyKeyDownProp) { event.stopPropagation(); } } - props.onKeyDown?.(event); + if (!isUsingLegacyKeyDownProp || isHandled) { + propsWithoutLegacyKeyProps.onKeyDown?.(event); + } }; const _onKeyUp = (event: KeyEvent) => { - const keyUpEvents = props.keyUpEvents; - if (keyUpEvents != null && !event.isPropagationStopped()) { - const isHandled = keyUpEvents.some( + let isHandled = false; + if (normalizedKeyUpEvents != null && !event.isPropagationStopped()) { + isHandled = normalizedKeyUpEvents.some( ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { return ( event.nativeEvent.key === key && @@ -616,11 +638,13 @@ function InternalTextInput(props: TextInputProps): React.Node { ); }, ); - if (isHandled === true) { + if (isHandled === true && !isUsingLegacyKeyUpProp) { event.stopPropagation(); } } - props.onKeyUp?.(event); + if (!isUsingLegacyKeyUpProp || isHandled) { + propsWithoutLegacyKeyProps.onKeyUp?.(event); + } }; // macOS] @@ -773,6 +797,8 @@ function InternalTextInput(props: TextInputProps): React.Node { caretHidden={caretHidden} dataDetectorTypes={props.dataDetectorTypes} focusable={tabIndex !== undefined ? !tabIndex : focusable} + keyDownEvents={normalizedKeyDownEvents} + keyUpEvents={normalizedKeyUpEvents} mostRecentEventCount={mostRecentEventCount} nativeID={id ?? props.nativeID} numberOfLines={props.rows ?? props.numberOfLines} diff --git a/packages/react-native/Libraries/Components/View/View.js b/packages/react-native/Libraries/Components/View/View.js index a3049473a04c..69159fde7d91 100644 --- a/packages/react-native/Libraries/Components/View/View.js +++ b/packages/react-native/Libraries/Components/View/View.js @@ -13,6 +13,9 @@ import type {ViewProps} from './ViewPropTypes'; import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; import TextAncestorContext from '../../Text/TextAncestorContext'; +import normalizeLegacyHandledKeyEvents, { + type LegacyHandledKeyEvent, +} from '../../Utilities/normalizeLegacyHandledKeyEvents'; import ViewNativeComponent from './ViewNativeComponent'; import * as React from 'react'; import {use} from 'react'; @@ -29,14 +32,31 @@ export default component View( ...props: ViewProps ) { const hasTextAncestor = use(TextAncestorContext); + const validKeysDown = + ((props: any).validKeysDown: ?$ReadOnlyArray); + const validKeysUp = + ((props: any).validKeysUp: ?$ReadOnlyArray); + const propsWithoutLegacyKeyProps = ({...props}: any); + delete propsWithoutLegacyKeyProps.validKeysDown; + delete propsWithoutLegacyKeyProps.validKeysUp; + const normalizedKeyDownEvents = + propsWithoutLegacyKeyProps.keyDownEvents ?? + normalizeLegacyHandledKeyEvents(validKeysDown); + const normalizedKeyUpEvents = + propsWithoutLegacyKeyProps.keyUpEvents ?? + normalizeLegacyHandledKeyEvents(validKeysUp); + const isUsingLegacyKeyDownProp = + propsWithoutLegacyKeyProps.keyDownEvents == null && validKeysDown != null; + const isUsingLegacyKeyUpProp = + propsWithoutLegacyKeyProps.keyUpEvents == null && validKeysUp != null; let actualView; // [macOS const _onKeyDown = (event: KeyEvent) => { - const keyDownEvents = props.keyDownEvents; - if (keyDownEvents != null && !event.isPropagationStopped()) { - const isHandled = keyDownEvents.some( + let isHandled = false; + if (normalizedKeyDownEvents != null && !event.isPropagationStopped()) { + isHandled = normalizedKeyDownEvents.some( ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { return ( event.nativeEvent.key === key && @@ -47,17 +67,19 @@ export default component View( ); }, ); - if (isHandled === true) { + if (isHandled === true && !isUsingLegacyKeyDownProp) { event.stopPropagation(); } } - props.onKeyDown?.(event); + if (!isUsingLegacyKeyDownProp || isHandled) { + propsWithoutLegacyKeyProps.onKeyDown?.(event); + } }; const _onKeyUp = (event: KeyEvent) => { - const keyUpEvents = props.keyUpEvents; - if (keyUpEvents != null && !event.isPropagationStopped()) { - const isHandled = keyUpEvents.some( + let isHandled = false; + if (normalizedKeyUpEvents != null && !event.isPropagationStopped()) { + isHandled = normalizedKeyUpEvents.some( ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { return ( event.nativeEvent.key === key && @@ -68,11 +90,13 @@ export default component View( ); }, ); - if (isHandled === true) { + if (isHandled === true && !isUsingLegacyKeyUpProp) { event.stopPropagation(); } } - props.onKeyUp?.(event); + if (!isUsingLegacyKeyUpProp || isHandled) { + propsWithoutLegacyKeyProps.onKeyUp?.(event); + } }; // macOS] @@ -96,11 +120,20 @@ export default component View( id, tabIndex, ...otherProps - } = props; + } = propsWithoutLegacyKeyProps; // Since we destructured props, we can now treat it as mutable const processedProps = otherProps as {...ViewProps}; + processedProps.keyDownEvents = normalizedKeyDownEvents; + processedProps.keyUpEvents = normalizedKeyUpEvents; + if (processedProps.onKeyDown != null) { + processedProps.onKeyDown = _onKeyDown; + } + if (processedProps.onKeyUp != null) { + processedProps.onKeyUp = _onKeyUp; + } + const parsedAriaLabelledBy = ariaLabelledBy?.split(/\s*,\s*/g); if (parsedAriaLabelledBy !== undefined) { processedProps.accessibilityLabelledBy = parsedAriaLabelledBy; @@ -195,7 +228,7 @@ export default component View( nativeID, tabIndex, ...otherProps - } = props; + } = propsWithoutLegacyKeyProps; const _accessibilityLabelledBy = ariaLabelledBy?.split(/\s*,\s*/g) ?? accessibilityLabelledBy; @@ -247,6 +280,8 @@ export default component View( : importantForAccessibility } nativeID={id ?? nativeID} + keyDownEvents={normalizedKeyDownEvents} + keyUpEvents={normalizedKeyUpEvents} // $FlowFixMe[exponential-spread] {...(otherProps.onKeyDown && {onKeyDown: _onKeyDown})} // [macOS] {...(otherProps.onKeyUp && {onKeyUp: _onKeyUp})} // [macOS] diff --git a/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js b/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js new file mode 100644 index 000000000000..f60644efc8d8 --- /dev/null +++ b/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {HandledKeyEvent} from '../Types/CoreEventTypes'; + +export type LegacyHandledKeyEvent = string | HandledKeyEvent; + +function expandLegacyHandledKeyEvent( + legacyHandledKeyEvent: LegacyHandledKeyEvent, +): Array { + if (typeof legacyHandledKeyEvent !== 'string') { + return [legacyHandledKeyEvent]; + } + + const expandedHandledKeyEvents = []; + for (const metaKey of [false, true]) { + for (const ctrlKey of [false, true]) { + for (const altKey of [false, true]) { + for (const shiftKey of [false, true]) { + expandedHandledKeyEvents.push({ + altKey, + ctrlKey, + key: legacyHandledKeyEvent, + metaKey, + shiftKey, + }); + } + } + } + } + + return expandedHandledKeyEvents; +} + +export default function normalizeLegacyHandledKeyEvents( + legacyHandledKeyEvents: ?$ReadOnlyArray, +): void | Array { + if (legacyHandledKeyEvents == null) { + return undefined; + } + + const normalizedHandledKeyEvents = []; + for (const legacyHandledKeyEvent of legacyHandledKeyEvents) { + normalizedHandledKeyEvents.push( + ...expandLegacyHandledKeyEvent(legacyHandledKeyEvent), + ); + } + + return normalizedHandledKeyEvents; +} \ No newline at end of file From cca944960df4bb469ecef30f25aba745bd0daac9 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 31 Mar 2026 13:20:56 -0500 Subject: [PATCH 2/4] more --- .../Components/Pressable/Pressable.js | 5 +- .../Components/TextInput/TextInput.js | 46 ++++++++++++----- .../Libraries/Components/View/View.js | 51 ++++++++++++------- .../normalizeLegacyHandledKeyEvents.js | 17 ++++--- 4 files changed, 79 insertions(+), 40 deletions(-) diff --git a/packages/react-native/Libraries/Components/Pressable/Pressable.js b/packages/react-native/Libraries/Components/Pressable/Pressable.js index e9d436100af1..72266bd2fa7e 100644 --- a/packages/react-native/Libraries/Components/Pressable/Pressable.js +++ b/packages/react-native/Libraries/Components/Pressable/Pressable.js @@ -383,7 +383,10 @@ function Pressable({ enableFocusRing: enableFocusRing !== false && !disabled, keyDownEvents: keyDownEvents ?? - (((props: any).validKeysDown: mixed) == null + // $FlowFixMe[unclear-type] Legacy props not in type definitions + (((props: any).validKeysDown: mixed) == null && + // $FlowFixMe[unclear-type] + ((props: any).passthroughAllKeyEvents: mixed) !== true ? [{key: ' '}, {key: 'Enter'}] : undefined), mouseDownCanMoveWindow: false, diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 35da07c4cabb..5efcfa232bb3 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -61,9 +61,11 @@ import StyleSheet, {type TextStyleProp} from '../../StyleSheet/StyleSheet'; import Text from '../../Text/Text'; import TextAncestorContext from '../../Text/TextAncestorContext'; import Platform from '../../Utilities/Platform'; +// [macOS import normalizeLegacyHandledKeyEvents, { type LegacyHandledKeyEvent, } from '../../Utilities/normalizeLegacyHandledKeyEvents'; +// macOS] import useMergeRefs from '../../Utilities/useMergeRefs'; import TextInputState from './TextInputState'; import invariant from 'invariant'; @@ -389,13 +391,18 @@ function useTextInputStateSynchronization({ * */ function InternalTextInput(props: TextInputProps): React.Node { - const validKeysDown = - ((props: any).validKeysDown: ?$ReadOnlyArray); - const validKeysUp = - ((props: any).validKeysUp: ?$ReadOnlyArray); + // [macOS Legacy keyboard event compat + // $FlowFixMe[unclear-type] + const validKeysDown = ((props: any).validKeysDown: ?$ReadOnlyArray); + // $FlowFixMe[unclear-type] + const validKeysUp = ((props: any).validKeysUp: ?$ReadOnlyArray); + // $FlowFixMe[unclear-type] + const passthroughAllKeyEvents = ((props: any).passthroughAllKeyEvents: ?boolean); + // $FlowFixMe[unclear-type] const propsWithoutLegacyKeyProps = ({...props}: any); delete propsWithoutLegacyKeyProps.validKeysDown; delete propsWithoutLegacyKeyProps.validKeysUp; + delete propsWithoutLegacyKeyProps.passthroughAllKeyEvents; const { 'aria-busy': ariaBusy, 'aria-checked': ariaChecked, @@ -411,16 +418,27 @@ function InternalTextInput(props: TextInputProps): React.Node { cursorColor, ...otherProps } = propsWithoutLegacyKeyProps; + const hasModernKeyDown = propsWithoutLegacyKeyProps.keyDownEvents != null; + const hasModernKeyUp = propsWithoutLegacyKeyProps.keyUpEvents != null; + const legacyPassthrough = + passthroughAllKeyEvents === true && !hasModernKeyDown; + const gateKeyDown = + !hasModernKeyDown && validKeysDown != null && !legacyPassthrough; + const gateKeyUp = + !hasModernKeyUp && validKeysUp != null && !legacyPassthrough; const normalizedKeyDownEvents = propsWithoutLegacyKeyProps.keyDownEvents ?? normalizeLegacyHandledKeyEvents(validKeysDown); const normalizedKeyUpEvents = propsWithoutLegacyKeyProps.keyUpEvents ?? normalizeLegacyHandledKeyEvents(validKeysUp); - const isUsingLegacyKeyDownProp = - propsWithoutLegacyKeyProps.keyDownEvents == null && validKeysDown != null; - const isUsingLegacyKeyUpProp = - propsWithoutLegacyKeyProps.keyUpEvents == null && validKeysUp != null; + const nativeKeyDownEvents = legacyPassthrough + ? undefined + : normalizedKeyDownEvents; + const nativeKeyUpEvents = legacyPassthrough + ? undefined + : normalizedKeyUpEvents; + // macOS] const inputRef = useRef(null); @@ -615,11 +633,11 @@ function InternalTextInput(props: TextInputProps): React.Node { ); }, ); - if (isHandled === true && !isUsingLegacyKeyDownProp) { + if (isHandled === true && hasModernKeyDown) { event.stopPropagation(); } } - if (!isUsingLegacyKeyDownProp || isHandled) { + if (!gateKeyDown || isHandled) { propsWithoutLegacyKeyProps.onKeyDown?.(event); } }; @@ -638,11 +656,11 @@ function InternalTextInput(props: TextInputProps): React.Node { ); }, ); - if (isHandled === true && !isUsingLegacyKeyUpProp) { + if (isHandled === true && hasModernKeyUp) { event.stopPropagation(); } } - if (!isUsingLegacyKeyUpProp || isHandled) { + if (!gateKeyUp || isHandled) { propsWithoutLegacyKeyProps.onKeyUp?.(event); } }; @@ -797,8 +815,8 @@ function InternalTextInput(props: TextInputProps): React.Node { caretHidden={caretHidden} dataDetectorTypes={props.dataDetectorTypes} focusable={tabIndex !== undefined ? !tabIndex : focusable} - keyDownEvents={normalizedKeyDownEvents} - keyUpEvents={normalizedKeyUpEvents} + keyDownEvents={nativeKeyDownEvents} + keyUpEvents={nativeKeyUpEvents} mostRecentEventCount={mostRecentEventCount} nativeID={id ?? props.nativeID} numberOfLines={props.rows ?? props.numberOfLines} diff --git a/packages/react-native/Libraries/Components/View/View.js b/packages/react-native/Libraries/Components/View/View.js index 69159fde7d91..c28209d7292b 100644 --- a/packages/react-native/Libraries/Components/View/View.js +++ b/packages/react-native/Libraries/Components/View/View.js @@ -13,9 +13,11 @@ import type {ViewProps} from './ViewPropTypes'; import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; import TextAncestorContext from '../../Text/TextAncestorContext'; +// [macOS import normalizeLegacyHandledKeyEvents, { type LegacyHandledKeyEvent, } from '../../Utilities/normalizeLegacyHandledKeyEvents'; +// macOS] import ViewNativeComponent from './ViewNativeComponent'; import * as React from 'react'; import {use} from 'react'; @@ -32,27 +34,40 @@ export default component View( ...props: ViewProps ) { const hasTextAncestor = use(TextAncestorContext); - const validKeysDown = - ((props: any).validKeysDown: ?$ReadOnlyArray); - const validKeysUp = - ((props: any).validKeysUp: ?$ReadOnlyArray); + // [macOS Legacy keyboard event compat + // $FlowFixMe[unclear-type] + const validKeysDown = ((props: any).validKeysDown: ?$ReadOnlyArray); + // $FlowFixMe[unclear-type] + const validKeysUp = ((props: any).validKeysUp: ?$ReadOnlyArray); + // $FlowFixMe[unclear-type] + const passthroughAllKeyEvents = ((props: any).passthroughAllKeyEvents: ?boolean); + // $FlowFixMe[unclear-type] const propsWithoutLegacyKeyProps = ({...props}: any); delete propsWithoutLegacyKeyProps.validKeysDown; delete propsWithoutLegacyKeyProps.validKeysUp; + delete propsWithoutLegacyKeyProps.passthroughAllKeyEvents; + const hasModernKeyDown = propsWithoutLegacyKeyProps.keyDownEvents != null; + const hasModernKeyUp = propsWithoutLegacyKeyProps.keyUpEvents != null; + const legacyPassthrough = + passthroughAllKeyEvents === true && !hasModernKeyDown; + const gateKeyDown = + !hasModernKeyDown && validKeysDown != null && !legacyPassthrough; + const gateKeyUp = + !hasModernKeyUp && validKeysUp != null && !legacyPassthrough; const normalizedKeyDownEvents = propsWithoutLegacyKeyProps.keyDownEvents ?? normalizeLegacyHandledKeyEvents(validKeysDown); const normalizedKeyUpEvents = propsWithoutLegacyKeyProps.keyUpEvents ?? normalizeLegacyHandledKeyEvents(validKeysUp); - const isUsingLegacyKeyDownProp = - propsWithoutLegacyKeyProps.keyDownEvents == null && validKeysDown != null; - const isUsingLegacyKeyUpProp = - propsWithoutLegacyKeyProps.keyUpEvents == null && validKeysUp != null; + const nativeKeyDownEvents = legacyPassthrough + ? undefined + : normalizedKeyDownEvents; + const nativeKeyUpEvents = legacyPassthrough + ? undefined + : normalizedKeyUpEvents; let actualView; - - // [macOS const _onKeyDown = (event: KeyEvent) => { let isHandled = false; if (normalizedKeyDownEvents != null && !event.isPropagationStopped()) { @@ -67,11 +82,11 @@ export default component View( ); }, ); - if (isHandled === true && !isUsingLegacyKeyDownProp) { + if (isHandled === true && hasModernKeyDown) { event.stopPropagation(); } } - if (!isUsingLegacyKeyDownProp || isHandled) { + if (!gateKeyDown || isHandled) { propsWithoutLegacyKeyProps.onKeyDown?.(event); } }; @@ -90,11 +105,11 @@ export default component View( ); }, ); - if (isHandled === true && !isUsingLegacyKeyUpProp) { + if (isHandled === true && hasModernKeyUp) { event.stopPropagation(); } } - if (!isUsingLegacyKeyUpProp || isHandled) { + if (!gateKeyUp || isHandled) { propsWithoutLegacyKeyProps.onKeyUp?.(event); } }; @@ -125,8 +140,8 @@ export default component View( // Since we destructured props, we can now treat it as mutable const processedProps = otherProps as {...ViewProps}; - processedProps.keyDownEvents = normalizedKeyDownEvents; - processedProps.keyUpEvents = normalizedKeyUpEvents; + processedProps.keyDownEvents = nativeKeyDownEvents; + processedProps.keyUpEvents = nativeKeyUpEvents; if (processedProps.onKeyDown != null) { processedProps.onKeyDown = _onKeyDown; } @@ -280,8 +295,8 @@ export default component View( : importantForAccessibility } nativeID={id ?? nativeID} - keyDownEvents={normalizedKeyDownEvents} - keyUpEvents={normalizedKeyUpEvents} + keyDownEvents={nativeKeyDownEvents} + keyUpEvents={nativeKeyUpEvents} // $FlowFixMe[exponential-spread] {...(otherProps.onKeyDown && {onKeyDown: _onKeyDown})} // [macOS] {...(otherProps.onKeyUp && {onKeyUp: _onKeyUp})} // [macOS] diff --git a/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js b/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js index f60644efc8d8..0c1e768c60a3 100644 --- a/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js +++ b/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js @@ -8,6 +8,8 @@ * @format */ +// [macOS] + import type {HandledKeyEvent} from '../Types/CoreEventTypes'; export type LegacyHandledKeyEvent = string | HandledKeyEvent; @@ -19,11 +21,12 @@ function expandLegacyHandledKeyEvent( return [legacyHandledKeyEvent]; } - const expandedHandledKeyEvents = []; - for (const metaKey of [false, true]) { - for (const ctrlKey of [false, true]) { - for (const altKey of [false, true]) { - for (const shiftKey of [false, true]) { + const expandedHandledKeyEvents: Array = []; + const bools: Array = [false, true]; + for (const metaKey of bools) { + for (const ctrlKey of bools) { + for (const altKey of bools) { + for (const shiftKey of bools) { expandedHandledKeyEvents.push({ altKey, ctrlKey, @@ -41,12 +44,12 @@ function expandLegacyHandledKeyEvent( export default function normalizeLegacyHandledKeyEvents( legacyHandledKeyEvents: ?$ReadOnlyArray, -): void | Array { +): void | $ReadOnlyArray { if (legacyHandledKeyEvents == null) { return undefined; } - const normalizedHandledKeyEvents = []; + const normalizedHandledKeyEvents: Array = []; for (const legacyHandledKeyEvent of legacyHandledKeyEvents) { normalizedHandledKeyEvents.push( ...expandLegacyHandledKeyEvent(legacyHandledKeyEvent), From a9e9a988f864cbc770a03db0e11c4a27c402e47d Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 31 Mar 2026 18:05:56 -0500 Subject: [PATCH 3/4] More --- .../Components/TextInput/TextInput.js | 105 ++--------- .../Libraries/Components/View/View.js | 115 +++--------- .../normalizeLegacyHandledKeyEvents.js | 165 +++++++++++++++--- .../KeyboardEventsExample.js | 101 +++++++++++ 4 files changed, 277 insertions(+), 209 deletions(-) diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 5efcfa232bb3..14e3e97a6ad0 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -16,9 +16,7 @@ import type { GestureResponderEvent, ScrollEvent, } from '../../Types/CoreEventTypes'; -// [macOS -import type {HandledKeyEvent, KeyEvent} from '../../Types/CoreEventTypes'; -// macOS] +// [macOS] import type { AutoCapitalize, EnterKeyHintType, @@ -62,8 +60,9 @@ import Text from '../../Text/Text'; import TextAncestorContext from '../../Text/TextAncestorContext'; import Platform from '../../Utilities/Platform'; // [macOS -import normalizeLegacyHandledKeyEvents, { - type LegacyHandledKeyEvent, +import processLegacyKeyProps, { + hasLegacyKeyProps, + stripLegacyKeyProps, } from '../../Utilities/normalizeLegacyHandledKeyEvents'; // macOS] import useMergeRefs from '../../Utilities/useMergeRefs'; @@ -391,18 +390,15 @@ function useTextInputStateSynchronization({ * */ function InternalTextInput(props: TextInputProps): React.Node { - // [macOS Legacy keyboard event compat - // $FlowFixMe[unclear-type] - const validKeysDown = ((props: any).validKeysDown: ?$ReadOnlyArray); + // [macOS Legacy keyboard event compat — to remove, delete this block and its import + const usingLegacyKeyboardProps = hasLegacyKeyProps(props); // $FlowFixMe[unclear-type] - const validKeysUp = ((props: any).validKeysUp: ?$ReadOnlyArray); - // $FlowFixMe[unclear-type] - const passthroughAllKeyEvents = ((props: any).passthroughAllKeyEvents: ?boolean); - // $FlowFixMe[unclear-type] - const propsWithoutLegacyKeyProps = ({...props}: any); - delete propsWithoutLegacyKeyProps.validKeysDown; - delete propsWithoutLegacyKeyProps.validKeysUp; - delete propsWithoutLegacyKeyProps.passthroughAllKeyEvents; + const propsWithoutLegacyKeyProps = usingLegacyKeyboardProps ? ({...props}: any) : props; + if (usingLegacyKeyboardProps) { + stripLegacyKeyProps(propsWithoutLegacyKeyProps); + } + const legacy = usingLegacyKeyboardProps ? processLegacyKeyProps(props) : null; + // macOS] const { 'aria-busy': ariaBusy, 'aria-checked': ariaChecked, @@ -418,27 +414,6 @@ function InternalTextInput(props: TextInputProps): React.Node { cursorColor, ...otherProps } = propsWithoutLegacyKeyProps; - const hasModernKeyDown = propsWithoutLegacyKeyProps.keyDownEvents != null; - const hasModernKeyUp = propsWithoutLegacyKeyProps.keyUpEvents != null; - const legacyPassthrough = - passthroughAllKeyEvents === true && !hasModernKeyDown; - const gateKeyDown = - !hasModernKeyDown && validKeysDown != null && !legacyPassthrough; - const gateKeyUp = - !hasModernKeyUp && validKeysUp != null && !legacyPassthrough; - const normalizedKeyDownEvents = - propsWithoutLegacyKeyProps.keyDownEvents ?? - normalizeLegacyHandledKeyEvents(validKeysDown); - const normalizedKeyUpEvents = - propsWithoutLegacyKeyProps.keyUpEvents ?? - normalizeLegacyHandledKeyEvents(validKeysUp); - const nativeKeyDownEvents = legacyPassthrough - ? undefined - : normalizedKeyDownEvents; - const nativeKeyUpEvents = legacyPassthrough - ? undefined - : normalizedKeyUpEvents; - // macOS] const inputRef = useRef(null); @@ -618,53 +593,7 @@ function InternalTextInput(props: TextInputProps): React.Node { props.onScroll && props.onScroll(event); }; - // [macOS - const _onKeyDown = (event: KeyEvent) => { - let isHandled = false; - if (normalizedKeyDownEvents != null && !event.isPropagationStopped()) { - isHandled = normalizedKeyDownEvents.some( - ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { - return ( - event.nativeEvent.key === key && - Boolean(metaKey) === event.nativeEvent.metaKey && - Boolean(ctrlKey) === event.nativeEvent.ctrlKey && - Boolean(altKey) === event.nativeEvent.altKey && - Boolean(shiftKey) === event.nativeEvent.shiftKey - ); - }, - ); - if (isHandled === true && hasModernKeyDown) { - event.stopPropagation(); - } - } - if (!gateKeyDown || isHandled) { - propsWithoutLegacyKeyProps.onKeyDown?.(event); - } - }; - - const _onKeyUp = (event: KeyEvent) => { - let isHandled = false; - if (normalizedKeyUpEvents != null && !event.isPropagationStopped()) { - isHandled = normalizedKeyUpEvents.some( - ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { - return ( - event.nativeEvent.key === key && - Boolean(metaKey) === event.nativeEvent.metaKey && - Boolean(ctrlKey) === event.nativeEvent.ctrlKey && - Boolean(altKey) === event.nativeEvent.altKey && - Boolean(shiftKey) === event.nativeEvent.shiftKey - ); - }, - ); - if (isHandled === true && hasModernKeyUp) { - event.stopPropagation(); - } - } - if (!gateKeyUp || isHandled) { - propsWithoutLegacyKeyProps.onKeyUp?.(event); - } - }; - // macOS] + // macOS] (legacy key handlers are in processLegacyKeyProps) let textInput = null; @@ -815,8 +744,8 @@ function InternalTextInput(props: TextInputProps): React.Node { caretHidden={caretHidden} dataDetectorTypes={props.dataDetectorTypes} focusable={tabIndex !== undefined ? !tabIndex : focusable} - keyDownEvents={nativeKeyDownEvents} - keyUpEvents={nativeKeyUpEvents} + {...(legacy != null && {keyDownEvents: legacy.keyDownEvents})} + {...(legacy != null && {keyUpEvents: legacy.keyUpEvents})} mostRecentEventCount={mostRecentEventCount} nativeID={id ?? props.nativeID} numberOfLines={props.rows ?? props.numberOfLines} @@ -825,8 +754,8 @@ function InternalTextInput(props: TextInputProps): React.Node { onContentSizeChange={props.onContentSizeChange} onFocus={_onFocus} // $FlowFixMe[exponential-spread] - {...(otherProps.onKeyDown && {onKeyDown: _onKeyDown})} // [macOS] - {...(otherProps.onKeyUp && {onKeyUp: _onKeyUp})} // [macOS] + {...(legacy?.onKeyDown != null && {onKeyDown: legacy.onKeyDown})} // [macOS] + {...(legacy?.onKeyUp != null && {onKeyUp: legacy.onKeyUp})} // [macOS] onScroll={_onScroll} onSelectionChange={_onSelectionChange} onSelectionChangeShouldSetResponder={emptyFunctionThatReturnsTrue} diff --git a/packages/react-native/Libraries/Components/View/View.js b/packages/react-native/Libraries/Components/View/View.js index c28209d7292b..369e9e01591a 100644 --- a/packages/react-native/Libraries/Components/View/View.js +++ b/packages/react-native/Libraries/Components/View/View.js @@ -8,14 +8,14 @@ * @format */ -import type {HandledKeyEvent, KeyEvent} from '../../Types/CoreEventTypes'; // [macOS] import type {ViewProps} from './ViewPropTypes'; import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; import TextAncestorContext from '../../Text/TextAncestorContext'; // [macOS -import normalizeLegacyHandledKeyEvents, { - type LegacyHandledKeyEvent, +import processLegacyKeyProps, { + hasLegacyKeyProps, + stripLegacyKeyProps, } from '../../Utilities/normalizeLegacyHandledKeyEvents'; // macOS] import ViewNativeComponent from './ViewNativeComponent'; @@ -34,85 +34,14 @@ export default component View( ...props: ViewProps ) { const hasTextAncestor = use(TextAncestorContext); - // [macOS Legacy keyboard event compat + // [macOS Legacy keyboard event compat — to remove, delete this block and its import + const usingLegacyKeyboardProps = hasLegacyKeyProps(props); // $FlowFixMe[unclear-type] - const validKeysDown = ((props: any).validKeysDown: ?$ReadOnlyArray); - // $FlowFixMe[unclear-type] - const validKeysUp = ((props: any).validKeysUp: ?$ReadOnlyArray); - // $FlowFixMe[unclear-type] - const passthroughAllKeyEvents = ((props: any).passthroughAllKeyEvents: ?boolean); - // $FlowFixMe[unclear-type] - const propsWithoutLegacyKeyProps = ({...props}: any); - delete propsWithoutLegacyKeyProps.validKeysDown; - delete propsWithoutLegacyKeyProps.validKeysUp; - delete propsWithoutLegacyKeyProps.passthroughAllKeyEvents; - const hasModernKeyDown = propsWithoutLegacyKeyProps.keyDownEvents != null; - const hasModernKeyUp = propsWithoutLegacyKeyProps.keyUpEvents != null; - const legacyPassthrough = - passthroughAllKeyEvents === true && !hasModernKeyDown; - const gateKeyDown = - !hasModernKeyDown && validKeysDown != null && !legacyPassthrough; - const gateKeyUp = - !hasModernKeyUp && validKeysUp != null && !legacyPassthrough; - const normalizedKeyDownEvents = - propsWithoutLegacyKeyProps.keyDownEvents ?? - normalizeLegacyHandledKeyEvents(validKeysDown); - const normalizedKeyUpEvents = - propsWithoutLegacyKeyProps.keyUpEvents ?? - normalizeLegacyHandledKeyEvents(validKeysUp); - const nativeKeyDownEvents = legacyPassthrough - ? undefined - : normalizedKeyDownEvents; - const nativeKeyUpEvents = legacyPassthrough - ? undefined - : normalizedKeyUpEvents; - - let actualView; - const _onKeyDown = (event: KeyEvent) => { - let isHandled = false; - if (normalizedKeyDownEvents != null && !event.isPropagationStopped()) { - isHandled = normalizedKeyDownEvents.some( - ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { - return ( - event.nativeEvent.key === key && - Boolean(metaKey) === event.nativeEvent.metaKey && - Boolean(ctrlKey) === event.nativeEvent.ctrlKey && - Boolean(altKey) === event.nativeEvent.altKey && - Boolean(shiftKey) === event.nativeEvent.shiftKey - ); - }, - ); - if (isHandled === true && hasModernKeyDown) { - event.stopPropagation(); - } - } - if (!gateKeyDown || isHandled) { - propsWithoutLegacyKeyProps.onKeyDown?.(event); - } - }; - - const _onKeyUp = (event: KeyEvent) => { - let isHandled = false; - if (normalizedKeyUpEvents != null && !event.isPropagationStopped()) { - isHandled = normalizedKeyUpEvents.some( - ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { - return ( - event.nativeEvent.key === key && - Boolean(metaKey) === event.nativeEvent.metaKey && - Boolean(ctrlKey) === event.nativeEvent.ctrlKey && - Boolean(altKey) === event.nativeEvent.altKey && - Boolean(shiftKey) === event.nativeEvent.shiftKey - ); - }, - ); - if (isHandled === true && hasModernKeyUp) { - event.stopPropagation(); - } - } - if (!gateKeyUp || isHandled) { - propsWithoutLegacyKeyProps.onKeyUp?.(event); - } - }; + const propsWithoutLegacyKeyProps = usingLegacyKeyboardProps ? ({...props}: any) : props; + if (usingLegacyKeyboardProps) { + stripLegacyKeyProps(propsWithoutLegacyKeyProps); + } + const legacy = usingLegacyKeyboardProps ? processLegacyKeyProps(props) : null; // macOS] if (ReactNativeFeatureFlags.reduceDefaultPropsInView()) { @@ -140,13 +69,15 @@ export default component View( // Since we destructured props, we can now treat it as mutable const processedProps = otherProps as {...ViewProps}; - processedProps.keyDownEvents = nativeKeyDownEvents; - processedProps.keyUpEvents = nativeKeyUpEvents; - if (processedProps.onKeyDown != null) { - processedProps.onKeyDown = _onKeyDown; - } - if (processedProps.onKeyUp != null) { - processedProps.onKeyUp = _onKeyUp; + if (legacy != null) { + processedProps.keyDownEvents = legacy.keyDownEvents; + processedProps.keyUpEvents = legacy.keyUpEvents; + if (legacy.onKeyDown != null) { + processedProps.onKeyDown = legacy.onKeyDown; + } + if (legacy.onKeyUp != null) { + processedProps.onKeyUp = legacy.onKeyUp; + } } const parsedAriaLabelledBy = ariaLabelledBy?.split(/\s*,\s*/g); @@ -295,11 +226,11 @@ export default component View( : importantForAccessibility } nativeID={id ?? nativeID} - keyDownEvents={nativeKeyDownEvents} - keyUpEvents={nativeKeyUpEvents} + {...(legacy != null && {keyDownEvents: legacy.keyDownEvents})} // [macOS] + {...(legacy != null && {keyUpEvents: legacy.keyUpEvents})} // [macOS] // $FlowFixMe[exponential-spread] - {...(otherProps.onKeyDown && {onKeyDown: _onKeyDown})} // [macOS] - {...(otherProps.onKeyUp && {onKeyUp: _onKeyUp})} // [macOS] + {...(legacy?.onKeyDown != null && {onKeyDown: legacy.onKeyDown})} // [macOS] + {...(legacy?.onKeyUp != null && {onKeyUp: legacy.onKeyUp})} // [macOS] ref={ref} /> ); diff --git a/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js b/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js index 0c1e768c60a3..4d534905ba7c 100644 --- a/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js +++ b/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js @@ -9,52 +9,159 @@ */ // [macOS] +// Legacy validKeysDown/validKeysUp/passthroughAllKeyEvents compat layer. +// When removing legacy support, delete this file and its call sites. -import type {HandledKeyEvent} from '../Types/CoreEventTypes'; +import type {HandledKeyEvent, KeyEvent} from '../Types/CoreEventTypes'; -export type LegacyHandledKeyEvent = string | HandledKeyEvent; +type LegacyHandledKeyEvent = string | HandledKeyEvent; -function expandLegacyHandledKeyEvent( - legacyHandledKeyEvent: LegacyHandledKeyEvent, -): Array { - if (typeof legacyHandledKeyEvent !== 'string') { - return [legacyHandledKeyEvent]; +function expandKey(entry: LegacyHandledKeyEvent): Array { + if (typeof entry !== 'string') { + return [entry]; } - - const expandedHandledKeyEvents: Array = []; + const out: Array = []; const bools: Array = [false, true]; for (const metaKey of bools) { for (const ctrlKey of bools) { for (const altKey of bools) { for (const shiftKey of bools) { - expandedHandledKeyEvents.push({ - altKey, - ctrlKey, - key: legacyHandledKeyEvent, - metaKey, - shiftKey, - }); + out.push({altKey, ctrlKey, key: entry, metaKey, shiftKey}); } } } } - - return expandedHandledKeyEvents; + return out; } -export default function normalizeLegacyHandledKeyEvents( - legacyHandledKeyEvents: ?$ReadOnlyArray, +function normalize( + legacy: ?$ReadOnlyArray, ): void | $ReadOnlyArray { - if (legacyHandledKeyEvents == null) { + if (legacy == null) { return undefined; } - - const normalizedHandledKeyEvents: Array = []; - for (const legacyHandledKeyEvent of legacyHandledKeyEvents) { - normalizedHandledKeyEvents.push( - ...expandLegacyHandledKeyEvent(legacyHandledKeyEvent), - ); + const result: Array = []; + for (const entry of legacy) { + result.push(...expandKey(entry)); } + return result; +} + +function matchesEvent( + events: $ReadOnlyArray, + event: KeyEvent, +): boolean { + return events.some( + ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => + event.nativeEvent.key === key && + Boolean(metaKey) === event.nativeEvent.metaKey && + Boolean(ctrlKey) === event.nativeEvent.ctrlKey && + Boolean(altKey) === event.nativeEvent.altKey && + Boolean(shiftKey) === event.nativeEvent.shiftKey, + ); +} + +export type LegacyKeyResult = { + keyDownEvents: void | $ReadOnlyArray, + keyUpEvents: void | $ReadOnlyArray, + onKeyDown: void | ((event: KeyEvent) => void), + onKeyUp: void | ((event: KeyEvent) => void), +}; + +/** + * Returns true if the props contain legacy key props that need processing. + */ +export function hasLegacyKeyProps(props: mixed): boolean { + // $FlowFixMe[unclear-type] + const p = (props: any); + return ( + p.validKeysDown != null || + p.validKeysUp != null || + p.passthroughAllKeyEvents != null + ); +} + +/** + * Strips legacy props from a props object (mutates). + */ +export function stripLegacyKeyProps(props: {+[string]: mixed}): void { + // $FlowFixMe[unclear-type] + const p = (props: any); + delete p.validKeysDown; + delete p.validKeysUp; + delete p.passthroughAllKeyEvents; +} + +/** + * Processes legacy validKeysDown/validKeysUp/passthroughAllKeyEvents props + * and returns the equivalent modern keyDownEvents/keyUpEvents and wrapped + * onKeyDown/onKeyUp handlers. + * + * Usage in component: + * if (hasLegacyKeyProps(props)) { + * const legacy = processLegacyKeyProps(props); + * // use legacy.keyDownEvents, legacy.onKeyDown, etc. + * } + */ +export default function processLegacyKeyProps( + // $FlowFixMe[unclear-type] + props: any, +): LegacyKeyResult { + const validKeysDown: ?$ReadOnlyArray = + props.validKeysDown; + const validKeysUp: ?$ReadOnlyArray = + props.validKeysUp; + const passthroughAllKeyEvents: ?boolean = props.passthroughAllKeyEvents; + + const hasModernKeyDown = props.keyDownEvents != null; + const hasModernKeyUp = props.keyUpEvents != null; + const legacyPassthrough = + passthroughAllKeyEvents === true && !hasModernKeyDown; + + const gateKeyDown = + !hasModernKeyDown && validKeysDown != null && !legacyPassthrough; + const gateKeyUp = + !hasModernKeyUp && validKeysUp != null && !legacyPassthrough; - return normalizedHandledKeyEvents; -} \ No newline at end of file + const normalizedDown = + props.keyDownEvents ?? normalize(validKeysDown); + const normalizedUp = + props.keyUpEvents ?? normalize(validKeysUp); + + const keyDownEvents = legacyPassthrough ? undefined : normalizedDown; + const keyUpEvents = legacyPassthrough ? undefined : normalizedUp; + + const onKeyDown = + props.onKeyDown != null + ? (event: KeyEvent) => { + let isHandled = false; + if (normalizedDown != null && !event.isPropagationStopped()) { + isHandled = matchesEvent(normalizedDown, event); + if (isHandled && hasModernKeyDown) { + event.stopPropagation(); + } + } + if (!gateKeyDown || isHandled) { + props.onKeyDown?.(event); + } + } + : undefined; + + const onKeyUp = + props.onKeyUp != null + ? (event: KeyEvent) => { + let isHandled = false; + if (normalizedUp != null && !event.isPropagationStopped()) { + isHandled = matchesEvent(normalizedUp, event); + if (isHandled && hasModernKeyUp) { + event.stopPropagation(); + } + } + if (!gateKeyUp || isHandled) { + props.onKeyUp?.(event); + } + } + : undefined; + + return {keyDownEvents, keyUpEvents, onKeyDown, onKeyUp}; +} diff --git a/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js b/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js index 6a5024e836b4..fea414741b00 100644 --- a/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js +++ b/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js @@ -293,6 +293,101 @@ function KeyboardEventExample(): React.Node { ); } +/** + * Tests legacy validKeysDown/validKeysUp compat layer. + * Exercises the JS shim that translates legacy props to modern keyDownEvents. + */ +function LegacyValidKeysExample(): React.Node { + const ref1 = React.useRef | null>(null); + const ref2 = React.useRef | null>(null); + const ref3 = React.useRef | null>(null); + const [eventLog, setEventLog] = React.useState>([]); + + function appendEvent(eventName: string, source?: string) { + const limit = 12; + setEventLog((current: Array) => { + const prefix = source != null ? `${source}: ` : ''; + return [`${prefix}${eventName}`].concat(current.slice(0, limit - 1)); + }); + } + + return ( + + + These components use the legacy validKeysDown / validKeysUp props. The JS + compat layer converts them to modern keyDownEvents / keyUpEvents under + the hood. + + + + + validKeysDown: ['a', 'b', 'Enter'] (string format) + + {/* $FlowFixMe[prop-missing] Legacy props not in type definitions */} + ref1.current?.focus()} + onKeyDown={(event: KeyEvent) => { + appendEvent(`keyDown: ${formatKeyEvent(event)}`, 'String keys'); + }}> + + Click to focus — press 'a', 'b', or Enter + + + + + + + validKeysDown: [Cmd+s, Ctrl+z] (object format) + + {/* $FlowFixMe[prop-missing] Legacy props not in type definitions */} + ref2.current?.focus()} + onKeyDown={(event: KeyEvent) => { + appendEvent(`keyDown: ${formatKeyEvent(event)}`, 'Modifier keys'); + }}> + + Click to focus — press Cmd+S or Ctrl+Z + + + + + + + passthroughAllKeyEvents + validKeysDown: ['Enter'] + + {/* $FlowFixMe[prop-missing] Legacy props not in type definitions */} + ref3.current?.focus()} + onKeyDown={(event: KeyEvent) => { + appendEvent(`keyDown: ${formatKeyEvent(event)}`, 'Passthrough'); + }}> + + Click to focus — ALL keys should log + + + + + setEventLog([])} /> + + ); +} + const styles = StyleSheet.create({ eventLogBox: { padding: 10, @@ -468,4 +563,10 @@ exports.examples = [ return ; }, }, + { + title: 'Legacy validKeysDown / validKeysUp compat', + render: function (): React.Node { + return ; + }, + }, ]; From 7e680798a71e172de7c2aec272070e712be36c3e Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 1 Apr 2026 14:33:46 -0700 Subject: [PATCH 4/4] fix: minimal additive diff for legacy validKeys compat layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keep existing _onKeyDown/_onKeyUp handlers from main, just read from effectiveProps instead of props - Fix Flow errors: restore let actualView, use Array instead of $ReadOnlyArray in LegacyKeyResult type - Zero deleted lines from upstream — purely additive changes Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Components/TextInput/TextInput.js | 70 +++++++++++++--- .../Libraries/Components/View/View.js | 83 ++++++++++++++----- .../normalizeLegacyHandledKeyEvents.js | 6 +- 3 files changed, 126 insertions(+), 33 deletions(-) diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 14e3e97a6ad0..3531ac22fd20 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -16,7 +16,9 @@ import type { GestureResponderEvent, ScrollEvent, } from '../../Types/CoreEventTypes'; -// [macOS] +// [macOS +import type {HandledKeyEvent, KeyEvent} from '../../Types/CoreEventTypes'; +// macOS] import type { AutoCapitalize, EnterKeyHintType, @@ -393,11 +395,19 @@ function InternalTextInput(props: TextInputProps): React.Node { // [macOS Legacy keyboard event compat — to remove, delete this block and its import const usingLegacyKeyboardProps = hasLegacyKeyProps(props); // $FlowFixMe[unclear-type] - const propsWithoutLegacyKeyProps = usingLegacyKeyboardProps ? ({...props}: any) : props; + const effectiveProps: any = usingLegacyKeyboardProps ? ({...props}: any) : props; if (usingLegacyKeyboardProps) { - stripLegacyKeyProps(propsWithoutLegacyKeyProps); + stripLegacyKeyProps(effectiveProps); + const legacy = processLegacyKeyProps(props); + effectiveProps.keyDownEvents = legacy.keyDownEvents; + effectiveProps.keyUpEvents = legacy.keyUpEvents; + if (legacy.onKeyDown != null) { + effectiveProps.onKeyDown = legacy.onKeyDown; + } + if (legacy.onKeyUp != null) { + effectiveProps.onKeyUp = legacy.onKeyUp; + } } - const legacy = usingLegacyKeyboardProps ? processLegacyKeyProps(props) : null; // macOS] const { 'aria-busy': ariaBusy, @@ -413,7 +423,7 @@ function InternalTextInput(props: TextInputProps): React.Node { selectionHandleColor, cursorColor, ...otherProps - } = propsWithoutLegacyKeyProps; + } = effectiveProps; const inputRef = useRef(null); @@ -593,7 +603,49 @@ function InternalTextInput(props: TextInputProps): React.Node { props.onScroll && props.onScroll(event); }; - // macOS] (legacy key handlers are in processLegacyKeyProps) + // [macOS + const _onKeyDown = (event: KeyEvent) => { + const keyDownEvents = effectiveProps.keyDownEvents; + if (keyDownEvents != null && !event.isPropagationStopped()) { + const isHandled = keyDownEvents.some( + ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { + return ( + event.nativeEvent.key === key && + Boolean(metaKey) === event.nativeEvent.metaKey && + Boolean(ctrlKey) === event.nativeEvent.ctrlKey && + Boolean(altKey) === event.nativeEvent.altKey && + Boolean(shiftKey) === event.nativeEvent.shiftKey + ); + }, + ); + if (isHandled === true) { + event.stopPropagation(); + } + } + effectiveProps.onKeyDown?.(event); + }; + + const _onKeyUp = (event: KeyEvent) => { + const keyUpEvents = effectiveProps.keyUpEvents; + if (keyUpEvents != null && !event.isPropagationStopped()) { + const isHandled = keyUpEvents.some( + ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { + return ( + event.nativeEvent.key === key && + Boolean(metaKey) === event.nativeEvent.metaKey && + Boolean(ctrlKey) === event.nativeEvent.ctrlKey && + Boolean(altKey) === event.nativeEvent.altKey && + Boolean(shiftKey) === event.nativeEvent.shiftKey + ); + }, + ); + if (isHandled === true) { + event.stopPropagation(); + } + } + effectiveProps.onKeyUp?.(event); + }; + // macOS] let textInput = null; @@ -744,8 +796,6 @@ function InternalTextInput(props: TextInputProps): React.Node { caretHidden={caretHidden} dataDetectorTypes={props.dataDetectorTypes} focusable={tabIndex !== undefined ? !tabIndex : focusable} - {...(legacy != null && {keyDownEvents: legacy.keyDownEvents})} - {...(legacy != null && {keyUpEvents: legacy.keyUpEvents})} mostRecentEventCount={mostRecentEventCount} nativeID={id ?? props.nativeID} numberOfLines={props.rows ?? props.numberOfLines} @@ -754,8 +804,8 @@ function InternalTextInput(props: TextInputProps): React.Node { onContentSizeChange={props.onContentSizeChange} onFocus={_onFocus} // $FlowFixMe[exponential-spread] - {...(legacy?.onKeyDown != null && {onKeyDown: legacy.onKeyDown})} // [macOS] - {...(legacy?.onKeyUp != null && {onKeyUp: legacy.onKeyUp})} // [macOS] + {...(otherProps.onKeyDown && {onKeyDown: _onKeyDown})} // [macOS] + {...(otherProps.onKeyUp && {onKeyUp: _onKeyUp})} // [macOS] onScroll={_onScroll} onSelectionChange={_onSelectionChange} onSelectionChangeShouldSetResponder={emptyFunctionThatReturnsTrue} diff --git a/packages/react-native/Libraries/Components/View/View.js b/packages/react-native/Libraries/Components/View/View.js index 369e9e01591a..7f8616010109 100644 --- a/packages/react-native/Libraries/Components/View/View.js +++ b/packages/react-native/Libraries/Components/View/View.js @@ -8,6 +8,7 @@ * @format */ +import type {HandledKeyEvent, KeyEvent} from '../../Types/CoreEventTypes'; // [macOS] import type {ViewProps} from './ViewPropTypes'; import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; @@ -34,14 +35,69 @@ export default component View( ...props: ViewProps ) { const hasTextAncestor = use(TextAncestorContext); + // [macOS Legacy keyboard event compat — to remove, delete this block and its import const usingLegacyKeyboardProps = hasLegacyKeyProps(props); // $FlowFixMe[unclear-type] - const propsWithoutLegacyKeyProps = usingLegacyKeyboardProps ? ({...props}: any) : props; + const effectiveProps: any = usingLegacyKeyboardProps ? ({...props}: any) : props; if (usingLegacyKeyboardProps) { - stripLegacyKeyProps(propsWithoutLegacyKeyProps); + stripLegacyKeyProps(effectiveProps); + const legacy = processLegacyKeyProps(props); + effectiveProps.keyDownEvents = legacy.keyDownEvents; + effectiveProps.keyUpEvents = legacy.keyUpEvents; + if (legacy.onKeyDown != null) { + effectiveProps.onKeyDown = legacy.onKeyDown; + } + if (legacy.onKeyUp != null) { + effectiveProps.onKeyUp = legacy.onKeyUp; + } } - const legacy = usingLegacyKeyboardProps ? processLegacyKeyProps(props) : null; + // macOS] + + let actualView; + + // [macOS + const _onKeyDown = (event: KeyEvent) => { + const keyDownEvents = effectiveProps.keyDownEvents; + if (keyDownEvents != null && !event.isPropagationStopped()) { + const isHandled = keyDownEvents.some( + ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { + return ( + event.nativeEvent.key === key && + Boolean(metaKey) === event.nativeEvent.metaKey && + Boolean(ctrlKey) === event.nativeEvent.ctrlKey && + Boolean(altKey) === event.nativeEvent.altKey && + Boolean(shiftKey) === event.nativeEvent.shiftKey + ); + }, + ); + if (isHandled === true) { + event.stopPropagation(); + } + } + effectiveProps.onKeyDown?.(event); + }; + + const _onKeyUp = (event: KeyEvent) => { + const keyUpEvents = effectiveProps.keyUpEvents; + if (keyUpEvents != null && !event.isPropagationStopped()) { + const isHandled = keyUpEvents.some( + ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { + return ( + event.nativeEvent.key === key && + Boolean(metaKey) === event.nativeEvent.metaKey && + Boolean(ctrlKey) === event.nativeEvent.ctrlKey && + Boolean(altKey) === event.nativeEvent.altKey && + Boolean(shiftKey) === event.nativeEvent.shiftKey + ); + }, + ); + if (isHandled === true) { + event.stopPropagation(); + } + } + effectiveProps.onKeyUp?.(event); + }; // macOS] if (ReactNativeFeatureFlags.reduceDefaultPropsInView()) { @@ -64,22 +120,11 @@ export default component View( id, tabIndex, ...otherProps - } = propsWithoutLegacyKeyProps; + } = effectiveProps; // Since we destructured props, we can now treat it as mutable const processedProps = otherProps as {...ViewProps}; - if (legacy != null) { - processedProps.keyDownEvents = legacy.keyDownEvents; - processedProps.keyUpEvents = legacy.keyUpEvents; - if (legacy.onKeyDown != null) { - processedProps.onKeyDown = legacy.onKeyDown; - } - if (legacy.onKeyUp != null) { - processedProps.onKeyUp = legacy.onKeyUp; - } - } - const parsedAriaLabelledBy = ariaLabelledBy?.split(/\s*,\s*/g); if (parsedAriaLabelledBy !== undefined) { processedProps.accessibilityLabelledBy = parsedAriaLabelledBy; @@ -174,7 +219,7 @@ export default component View( nativeID, tabIndex, ...otherProps - } = propsWithoutLegacyKeyProps; + } = effectiveProps; const _accessibilityLabelledBy = ariaLabelledBy?.split(/\s*,\s*/g) ?? accessibilityLabelledBy; @@ -226,11 +271,9 @@ export default component View( : importantForAccessibility } nativeID={id ?? nativeID} - {...(legacy != null && {keyDownEvents: legacy.keyDownEvents})} // [macOS] - {...(legacy != null && {keyUpEvents: legacy.keyUpEvents})} // [macOS] // $FlowFixMe[exponential-spread] - {...(legacy?.onKeyDown != null && {onKeyDown: legacy.onKeyDown})} // [macOS] - {...(legacy?.onKeyUp != null && {onKeyUp: legacy.onKeyUp})} // [macOS] + {...(otherProps.onKeyDown && {onKeyDown: _onKeyDown})} // [macOS] + {...(otherProps.onKeyUp && {onKeyUp: _onKeyUp})} // [macOS] ref={ref} /> ); diff --git a/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js b/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js index 4d534905ba7c..9a6a6e8a5a9b 100644 --- a/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js +++ b/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js @@ -36,7 +36,7 @@ function expandKey(entry: LegacyHandledKeyEvent): Array { function normalize( legacy: ?$ReadOnlyArray, -): void | $ReadOnlyArray { +): void | Array { if (legacy == null) { return undefined; } @@ -62,8 +62,8 @@ function matchesEvent( } export type LegacyKeyResult = { - keyDownEvents: void | $ReadOnlyArray, - keyUpEvents: void | $ReadOnlyArray, + keyDownEvents: void | Array, + keyUpEvents: void | Array, onKeyDown: void | ((event: KeyEvent) => void), onKeyUp: void | ((event: KeyEvent) => void), };