diff --git a/Libraries/Components/TextInput/TextInput.js b/Libraries/Components/TextInput/TextInput.js index aba912d03a96bd..0124cfb2c49532 100644 --- a/Libraries/Components/TextInput/TextInput.js +++ b/Libraries/Components/TextInput/TextInput.js @@ -21,19 +21,18 @@ const TextInputState = require('./TextInputState'); const TouchableWithoutFeedback = require('../Touchable/TouchableWithoutFeedback'); const invariant = require('invariant'); +const nullthrows = require('nullthrows'); const requireNativeComponent = require('../../ReactNative/requireNativeComponent'); +const setAndForwardRef = require('../../Utilities/setAndForwardRef'); import type {TextStyleProp, ViewStyleProp} from '../../StyleSheet/StyleSheet'; import type {ColorValue} from '../../StyleSheet/StyleSheetTypes'; import type {ViewProps} from '../View/ViewPropTypes'; import type {SyntheticEvent, ScrollEvent} from '../../Types/CoreEventTypes'; import type {PressEvent} from '../../Types/CoreEventTypes'; -import type { - HostComponent, - MeasureOnSuccessCallback, - MeasureInWindowOnSuccessCallback, - MeasureLayoutOnSuccessCallback, -} from '../../Renderer/shims/ReactNativeTypes'; +import type {HostComponent} from '../../Renderer/shims/ReactNativeTypes'; + +type ReactRefSetter = {current: null | T} | ((ref: null | T) => mixed); let AndroidTextInput; let RCTMultilineTextInputView; @@ -676,6 +675,10 @@ export type Props = $ReadOnly<{| * If `true`, contextMenuHidden is hidden. The default value is `false`. */ contextMenuHidden?: ?boolean, + + forwardedRef?: ?ReactRefSetter< + React.ElementRef> & ImperativeMethods, + >, |}>; type DefaultProps = $ReadOnly<{| @@ -684,11 +687,11 @@ type DefaultProps = $ReadOnly<{| underlineColorAndroid: 'transparent', |}>; -type State = {| - currentlyFocusedField: typeof TextInputState.currentlyFocusedField, - focusTextInput: typeof TextInputState.focusTextInput, - blurTextInput: typeof TextInputState.blurTextInput, -|}; +type ImperativeMethods = $ReadOnly<{| + clear: () => void, + isFocused: () => boolean, + getNativeRef: () => ?React.ElementRef>, +|}>; const emptyFunctionThatReturnsTrue = () => true; @@ -803,22 +806,14 @@ const emptyFunctionThatReturnsTrue = () => true; * or control this param programmatically with native code. * */ -class TextInput extends React.Component { +class InternalTextInput extends React.Component { static defaultProps: DefaultProps = { allowFontScaling: true, rejectResponderTermination: true, underlineColorAndroid: 'transparent', }; - static propTypes = DeprecatedTextInputPropTypes; - - static State: State = { - currentlyFocusedField: TextInputState.currentlyFocusedField, - focusTextInput: TextInputState.focusTextInput, - blurTextInput: TextInputState.blurTextInput, - }; - - _inputRef: ?React.ElementRef> = null; + _inputRef: null | React.ElementRef> = null; _focusSubscription: ?Function = undefined; _lastNativeText: ?Stringish = null; _lastNativeSelection: ?Selection = null; @@ -833,7 +828,11 @@ class TextInput extends React.Component { } if (this.props.autoFocus) { - this._rafId = requestAnimationFrame(this.focus); + this._rafId = requestAnimationFrame(() => { + if (this._inputRef) { + this._inputRef.focus(); + } + }); } } @@ -874,7 +873,7 @@ class TextInput extends React.Component { componentWillUnmount() { this._focusSubscription && this._focusSubscription.remove(); if (this.isFocused()) { - this.blur(); + nullthrows(this._inputRef).blur(); } const tag = ReactNative.findNodeHandle(this._inputRef); if (tag != null) { @@ -889,7 +888,9 @@ class TextInput extends React.Component { * Removes all text from the `TextInput`. */ clear: () => void = () => { - this.setNativeProps({text: ''}); + if (this._inputRef != null) { + this._inputRef.setNativeProps({text: ''}); + } }; /** @@ -906,35 +907,6 @@ class TextInput extends React.Component { return this._inputRef; }; - // From NativeMethodsMixin - // We need these instead of using forwardRef because we also have the other - // methods we expose - blur: () => void = () => { - this._inputRef && this._inputRef.blur(); - }; - focus: () => void = () => { - this._inputRef && this._inputRef.focus(); - }; - measure: (callback: MeasureOnSuccessCallback) => void = callback => { - this._inputRef && this._inputRef.measure(callback); - }; - measureInWindow: ( - callback: MeasureInWindowOnSuccessCallback, - ) => void = callback => { - this._inputRef && this._inputRef.measureInWindow(callback); - }; - measureLayout: ( - relativeToNativeNode: number | React.ElementRef>, - onSuccess: MeasureLayoutOnSuccessCallback, - onFail?: () => void, - ) => void = (relativeToNativeNode, onSuccess, onFail) => { - this._inputRef && - this._inputRef.measureLayout(relativeToNativeNode, onSuccess, onFail); - }; - setNativeProps: (nativeProps: Object) => void = nativeProps => { - this._inputRef && this._inputRef.setNativeProps(nativeProps); - }; - render(): React.Node { let textInput = null; let additionalTouchableProps: {| @@ -1045,13 +1017,44 @@ class TextInput extends React.Component { : ''; } - _setNativeRef = (ref: any) => { - this._inputRef = ref; - }; + _setNativeRef = setAndForwardRef({ + getForwardedRef: () => this.props.forwardedRef, + setLocalRef: ref => { + this._inputRef = ref; + + /* + Hi reader from the future. I'm sorry for this. + + This is a hack. Ideally we would forwardRef to the underlying + host component. However, since TextInput has it's own methods that can be + called as well, if we used the standard forwardRef then these + methods wouldn't be accessible and thus be a breaking change. + + We have a couple of options of how to handle this: + - Return a new ref with everything we methods from both. This is problematic + because we need React to also know it is a host component which requires + internals of the class implementation of the ref. + - Break the API and have some other way to call one set of the methods or + the other. This is our long term approach as we want to eventually + get the methods on host components off the ref. So instead of calling + ref.measure() you might call ReactNative.measure(ref). This would hopefully + let the ref for TextInput then have the methods like `.clear`. Or we do it + the other way and make it TextInput.clear(textInputRef) which would be fine + too. Either way though is a breaking change that is longer term. + - Mutate this ref. :( Gross, but accomplishes what we need in the meantime + before we can get to the long term breaking change. + */ + if (ref) { + ref.clear = this.clear; + ref.isFocused = this.isFocused; + ref.getNativeRef = this.getNativeRef; + } + }, + }); _onPress = (event: PressEvent) => { if (this.props.editable || this.props.editable === undefined) { - this.focus(); + nullthrows(this._inputRef).focus(); } }; @@ -1117,16 +1120,44 @@ class TextInput extends React.Component { }; } -class InternalTextInputType extends ReactNative.NativeComponent { - clear() {} - - getNativeRef(): ?React.ElementRef> {} - - // $FlowFixMe - isFocused(): boolean {} -} +const ExportedForwardRef: React.AbstractComponent< + React.ElementConfig, + React.ElementRef> & ImperativeMethods, +> = React.forwardRef(function TextInput( + props, + forwardedRef: ReactRefSetter< + React.ElementRef> & ImperativeMethods, + >, +) { + return ; +}); -const TypedTextInput = ((TextInput: any): Class); +// $FlowFixMe +ExportedForwardRef.defaultProps = { + allowFontScaling: true, + rejectResponderTermination: true, + underlineColorAndroid: 'transparent', +}; + +// TODO: Deprecate this +// $FlowFixMe +ExportedForwardRef.propTypes = DeprecatedTextInputPropTypes; + +// $FlowFixMe +ExportedForwardRef.State = { + currentlyFocusedField: TextInputState.currentlyFocusedField, + focusTextInput: TextInputState.focusTextInput, + blurTextInput: TextInputState.blurTextInput, +}; + +type TextInputComponentStatics = $ReadOnly<{| + State: $ReadOnly<{| + currentlyFocusedField: typeof TextInputState.currentlyFocusedField, + focusTextInput: typeof TextInputState.focusTextInput, + blurTextInput: typeof TextInputState.blurTextInput, + |}>, + propTypes: typeof DeprecatedTextInputPropTypes, +|}>; const styles = StyleSheet.create({ multilineInput: { @@ -1137,4 +1168,11 @@ const styles = StyleSheet.create({ }, }); -module.exports = TypedTextInput; +module.exports = ((ExportedForwardRef: any): React.AbstractComponent< + React.ElementConfig, + $ReadOnly<{| + ...React.ElementRef>, + ...ImperativeMethods, + |}>, +> & + TextInputComponentStatics); diff --git a/Libraries/Components/TextInput/__tests__/TextInput-test.js b/Libraries/Components/TextInput/__tests__/TextInput-test.js index 73b81a4019a1d3..c8a10e4e7d0bd2 100644 --- a/Libraries/Components/TextInput/__tests__/TextInput-test.js +++ b/Libraries/Components/TextInput/__tests__/TextInput-test.js @@ -16,7 +16,6 @@ const ReactTestRenderer = require('react-test-renderer'); const TextInput = require('../TextInput'); const ReactNative = require('../../../Renderer/shims/ReactNative'); -import type {FocusEvent} from '../TextInput'; import Component from '@reactions/component'; const {enter} = require('../../../Utilities/ReactNativeTestTools'); @@ -25,16 +24,19 @@ jest.unmock('../TextInput'); describe('TextInput tests', () => { let input; + let inputRef; let onChangeListener; let onChangeTextListener; const initialValue = 'initialValue'; beforeEach(() => { + inputRef = React.createRef(null); onChangeListener = jest.fn(); onChangeTextListener = jest.fn(); const renderTree = ReactTestRenderer.create( {({setState, state}) => ( { onChangeTextListener(text); @@ -50,14 +52,20 @@ describe('TextInput tests', () => { input = renderTree.root.findByType(TextInput); }); it('has expected instance functions', () => { - expect(input.instance.isFocused).toBeInstanceOf(Function); // Would have prevented S168585 - expect(input.instance.clear).toBeInstanceOf(Function); - expect(input.instance.focus).toBeInstanceOf(Function); - expect(input.instance.blur).toBeInstanceOf(Function); - expect(input.instance.setNativeProps).toBeInstanceOf(Function); - expect(input.instance.measure).toBeInstanceOf(Function); - expect(input.instance.measureInWindow).toBeInstanceOf(Function); - expect(input.instance.measureLayout).toBeInstanceOf(Function); + expect(inputRef.current.isFocused).toBeInstanceOf(Function); // Would have prevented S168585 + expect(inputRef.current.clear).toBeInstanceOf(Function); + expect(inputRef.current.focus).toBeInstanceOf(jest.fn().constructor); + expect(inputRef.current.blur).toBeInstanceOf(jest.fn().constructor); + expect(inputRef.current.setNativeProps).toBeInstanceOf( + jest.fn().constructor, + ); + expect(inputRef.current.measure).toBeInstanceOf(jest.fn().constructor); + expect(inputRef.current.measureInWindow).toBeInstanceOf( + jest.fn().constructor, + ); + expect(inputRef.current.measureLayout).toBeInstanceOf( + jest.fn().constructor, + ); }); it('calls onChange callbacks', () => { expect(input.props.value).toBe(initialValue); diff --git a/Libraries/Renderer/shims/ReactNativeTypes.js b/Libraries/Renderer/shims/ReactNativeTypes.js index d9d24f4fdb3987..7aa6c59a67b736 100644 --- a/Libraries/Renderer/shims/ReactNativeTypes.js +++ b/Libraries/Renderer/shims/ReactNativeTypes.js @@ -119,10 +119,7 @@ export type NativeMethods = { }; export type NativeMethodsMixinType = NativeMethods; -export type HostComponent = AbstractComponent< - T, - $ReadOnly<$Exact>, ->; +export type HostComponent = AbstractComponent>; type SecretInternalsType = { NativeMethodsMixin: NativeMethodsMixinType, diff --git a/jest/setup.js b/jest/setup.js index 1e21cead5f6cb4..09bc4b3d2b64b6 100644 --- a/jest/setup.js +++ b/jest/setup.js @@ -89,7 +89,10 @@ jest mockComponent('../Libraries/Text/Text', MockNativeMethods), ) .mock('../Libraries/Components/TextInput/TextInput', () => - mockComponent('../Libraries/Components/TextInput/TextInput'), + mockComponent( + '../Libraries/Components/TextInput/TextInput', + MockNativeMethods, + ), ) .mock('../Libraries/Modal/Modal', () => mockComponent('../Libraries/Modal/Modal'), @@ -312,6 +315,14 @@ jest render() { return React.createElement(viewName, this.props, this.props.children); } + + // The methods that exist on host components + blur = jest.fn(); + focus = jest.fn(); + measure = jest.fn(); + measureInWindow = jest.fn(); + measureLayout = jest.fn(); + setNativeProps = jest.fn(); }; if (viewName === 'RCTView') {