From b3dab74cd1ca44ea82883183c1515f0ab22c8aaf Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Mon, 8 Dec 2025 15:36:14 -0800 Subject: [PATCH 1/3] Polyfill setSelectionRange, selectionStart, selectionEnd Polyfill imperative selection APIs for input and textarea components --- .../createStrictDOMTextInputComponent.js | 37 +++++++++++- .../src/native/modules/useStrictDOMElement.js | 33 +++++++++++ .../src/types/renderer.native.js | 1 + .../__snapshots__/compat-test.native.js.snap | 2 + .../css-themes-test.native.js.snap | 3 + .../__snapshots__/html-test.js.snap-native | 10 ++++ .../__snapshots__/html-test.native.js.snap | 59 +++++++++++++++++++ .../tests/html/html-refs-test.native.js | 10 ++++ packages/website/docs/api/03-html/index.md | 14 ++--- 9 files changed, 160 insertions(+), 9 deletions(-) diff --git a/packages/react-strict-dom/src/native/modules/createStrictDOMTextInputComponent.js b/packages/react-strict-dom/src/native/modules/createStrictDOMTextInputComponent.js index d9b70d28..b95de51d 100644 --- a/packages/react-strict-dom/src/native/modules/createStrictDOMTextInputComponent.js +++ b/packages/react-strict-dom/src/native/modules/createStrictDOMTextInputComponent.js @@ -14,6 +14,7 @@ import * as React from 'react'; import * as ReactNative from '../react-native'; import { errorMsg } from '../../shared/logUtils'; +import { mergeRefs } from '../../shared/mergeRefs'; import { useNativeProps } from './useNativeProps'; import { useStrictDOMElement } from './useStrictDOMElement'; @@ -23,8 +24,22 @@ const AnimatedTextInput = ReactNative.Animated.createAnimatedComponent< // $FlowFixMe: React Native animated component typing issue >(ReactNative.TextInput); +// $FlowFixMe[unclear-type] +type Node = any; + type StrictInputProps = StrictReactDOMInputProps | StrictReactDOMTextAreaProps; +// Helper to update cached selection state for selectionStart/End polyfill +function updateCachedSelection( + node: ?Node, + selection: ?{ start: number, end: number } +) { + if (node != null && selection != null) { + node._selectionStart = selection.start; + node._selectionEnd = selection.end; + } +} + export function createStrictDOMTextInputComponent( tagName: string, defaultProps?: P @@ -33,6 +48,7 @@ export function createStrictDOMTextInputComponent( let NativeComponent: | typeof ReactNative.TextInput | typeof AnimatedTextInput = ReactNative.TextInput; + const nodeRef = React.useRef(null); const elementRef = useStrictDOMElement(ref, { tagName }); const { @@ -45,6 +61,7 @@ export function createStrictDOMTextInputComponent( onChange, onInput, onKeyDown, + onSelectionChange, placeholder, readOnly, rows, @@ -124,7 +141,9 @@ export function createStrictDOMTextInputComponent( } if (onChange != null || onInput != null) { nativeProps.onChange = function (e) { - const { text } = e.nativeEvent; + const { text, selection } = e.nativeEvent; + // Update cached selection state immediately to ensure sync with onChange + updateCachedSelection(nodeRef.current, selection); if (onInput != null) { onInput({ target: { @@ -165,6 +184,14 @@ export function createStrictDOMTextInputComponent( }); }; } + // Part of polyfill for selectionStart/End + nativeProps.onSelectionChange = function (e) { + const { selection } = e.nativeEvent; + updateCachedSelection(nodeRef.current, selection); + if (onSelectionChange != null) { + onSelectionChange(e); + } + }; if (placeholder != null) { nativeProps.placeholder = placeholder; } @@ -178,7 +205,13 @@ export function createStrictDOMTextInputComponent( nativeProps.value = value; } - nativeProps.ref = elementRef; + nativeProps.ref = React.useMemo( + () => + mergeRefs((node) => { + nodeRef.current = node; + }, elementRef), + [elementRef] + ); // Use Animated components if necessary if (nativeProps.animated === true) { diff --git a/packages/react-strict-dom/src/native/modules/useStrictDOMElement.js b/packages/react-strict-dom/src/native/modules/useStrictDOMElement.js index 68bc7e8a..285007bb 100644 --- a/packages/react-strict-dom/src/native/modules/useStrictDOMElement.js +++ b/packages/react-strict-dom/src/native/modules/useStrictDOMElement.js @@ -120,6 +120,39 @@ function getOrCreateStrictRef( } } }); + } else if (tagName === 'input' || tagName === 'textarea') { + const setSelectionRange = node?.setSelectionRange; + if (setSelectionRange == null) { + // $FlowFixMe[prop-missing] + Object.defineProperty(strictRef, 'setSelectionRange', { + value: (a: number, b: number) => { + node.setSelection(a, b); + // Update cached selection state + node._selectionStart = a; + node._selectionEnd = b; + }, + configurable: true, + writable: true + }); + } + const selectionStart = node?.selectionStart; + if (selectionStart == null) { + // $FlowFixMe[prop-missing] + Object.defineProperty(strictRef, 'selectionStart', { + get() { + return node._selectionStart ?? 0; + } + }); + } + const selectionEnd = node?.selectionEnd; + if (selectionEnd == null) { + // $FlowFixMe[prop-missing] + Object.defineProperty(strictRef, 'selectionEnd', { + get() { + return node._selectionEnd ?? 0; + } + }); + } } memoizedStrictRefs.set(node, strictRef); diff --git a/packages/react-strict-dom/src/types/renderer.native.js b/packages/react-strict-dom/src/types/renderer.native.js index fbfd86d4..66e6b6a8 100644 --- a/packages/react-strict-dom/src/types/renderer.native.js +++ b/packages/react-strict-dom/src/types/renderer.native.js @@ -99,6 +99,7 @@ type ReactNativeProps = { onPointerUp?: ViewProps['onPointerUp'], onPress?: ?(event: PressEvent) => void, onScroll?: $FlowFixMe, + onSelectionChange?: TextInputProps['onSelectionChange'], onSubmitEditing?: TextInputProps['onSubmitEditing'], onTouchCancel?: ViewProps['onTouchCancel'], onTouchStart?: ViewProps['onTouchStart'], diff --git a/packages/react-strict-dom/tests/compat/__snapshots__/compat-test.native.js.snap b/packages/react-strict-dom/tests/compat/__snapshots__/compat-test.native.js.snap index 70e4cada..24b35837 100644 --- a/packages/react-strict-dom/tests/compat/__snapshots__/compat-test.native.js.snap +++ b/packages/react-strict-dom/tests/compat/__snapshots__/compat-test.native.js.snap @@ -28,6 +28,7 @@ exports[` "as" equals "img": as=img 1`] = ` exports[` "as" equals "input": as=input 1`] = ` "as" equals "textarea": as=textarea 1`] = ` accessibilityLabel="label" multiline={true} numberOfLines={3} + onSelectionChange={[Function]} ref={[Function]} style={{}} /> diff --git a/packages/react-strict-dom/tests/css/__snapshots__/css-themes-test.native.js.snap b/packages/react-strict-dom/tests/css/__snapshots__/css-themes-test.native.js.snap index c37be260..942df477 100644 --- a/packages/react-strict-dom/tests/css/__snapshots__/css-themes-test.native.js.snap +++ b/packages/react-strict-dom/tests/css/__snapshots__/css-themes-test.native.js.snap @@ -57,6 +57,7 @@ exports[`css.* themes inherited themes 1`] = ` Expect color:green , "img" supports inline event handlers 1`] = ` exports[` "input" default rendering 1`] = ` "input" default rendering 1`] = ` exports[` "input" ignores and warns about unsupported attributes 1`] = ` "input" supports additional input attributes 1`] = ` focusable={false} maxLength="10" onChange={[Function]} + onSelectionChange={[Function]} placeholder="Placeholder" ref={[Function]} style={ @@ -3206,6 +3209,7 @@ exports[` "input" supports global attributes 1`] = ` importantForAccessibility="no-hide-descendants" inputMode="numeric" nativeID="some-id" + onSelectionChange={[Function]} ref={[Function]} role="article" spellCheck={true} @@ -3250,6 +3254,7 @@ exports[` "input" supports inline event handlers 1`] = ` onPointerUp={[Function]} onPress={[Function]} onScroll={[Function]} + onSelectionChange={[Function]} onSubmitEditing={[Function]} onTouchCancel={[Function]} onTouchEnd={[Function]} @@ -5402,6 +5407,7 @@ exports[` "sup" supports inline event handlers 1`] = ` exports[` "textarea" default rendering 1`] = ` "textarea" default rendering 1`] = ` exports[` "textarea" ignores and warns about unsupported attributes 1`] = ` "textarea" supports additional textarea attributes 1`] = ` multiline={true} numberOfLines={3} onChange={[Function]} + onSelectionChange={[Function]} placeholder="Placeholder" ref={[Function]} style={ @@ -5488,6 +5496,7 @@ exports[` "textarea" supports global attributes 1`] = ` importantForAccessibility="no-hide-descendants" multiline={true} nativeID="some-id" + onSelectionChange={[Function]} ref={[Function]} role="article" spellCheck={true} @@ -5534,6 +5543,7 @@ exports[` "textarea" supports inline event handlers 1`] = ` onPointerUp={[Function]} onPress={[Function]} onScroll={[Function]} + onSelectionChange={[Function]} onSubmitEditing={[Function]} onTouchCancel={[Function]} onTouchEnd={[Function]} diff --git a/packages/react-strict-dom/tests/html/__snapshots__/html-test.native.js.snap b/packages/react-strict-dom/tests/html/__snapshots__/html-test.native.js.snap index 60c1d112..b7d6a17b 100644 --- a/packages/react-strict-dom/tests/html/__snapshots__/html-test.native.js.snap +++ b/packages/react-strict-dom/tests/html/__snapshots__/html-test.native.js.snap @@ -84,6 +84,7 @@ exports[` (native polyfills) polyfills: inheritence inherited styles 1`] Inherits text styles (native polyfills) polyfills: props "srcSet" prop with l exports[` (native polyfills) polyfills: props "autoComplete" prop: "additional-name" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "address-line1" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "address-line2" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "bday" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "bday-day" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "bday-month" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "bday-year" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "cc-csc" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "cc-exp" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "cc-exp-month" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "cc-exp-year" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "cc-number" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "country" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "current-password" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "email" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "family-name" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "given-name" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "honorific-prefix" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "honorific-suffix" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "name" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "new-password" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "nickname" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "off" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "on" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "one-time-code" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "organization" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "organization-title" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "postal-code" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "sex" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "street-address" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "tel" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "tel-country-code" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "tel-national" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "url" 1`] = ` (native polyfills) polyfills: props "autoComplete" pro exports[` (native polyfills) polyfills: props "autoComplete" prop: "username" 1`] = ` (native polyfills) polyfills: props "disabled" prop 1` disabled={true} editable={false} focusable={false} + onSelectionChange={[Function]} ref={[Function]} style={ { @@ -754,6 +791,7 @@ exports[` (native polyfills) polyfills: props "disabled" prop 1` exports[` (native polyfills) polyfills: props "style" prop 1`] = ` (native polyfills) polyfills: props "style" prop 1`] = exports[` (native polyfills) polyfills: props "type" prop: "email" 1`] = ` (native polyfills) polyfills: props "type" prop: "emai exports[` (native polyfills) polyfills: props "type" prop: "number" 1`] = ` (native polyfills) polyfills: props "type" prop: "numb exports[` (native polyfills) polyfills: props "type" prop: "password" 1`] = ` (native polyfills) polyfills: props "type" prop: "pass exports[` (native polyfills) polyfills: props "type" prop: "search" 1`] = ` (native polyfills) polyfills: props "type" prop: "sear exports[` (native polyfills) polyfills: props "type" prop: "tel" 1`] = ` (native polyfills) polyfills: props "type" prop: "tel" exports[` (native polyfills) polyfills: props "type" prop: "url" 1`] = ` (native polyfills) polyfills: props