From db26af647c97dc3126049f9dfab4f87b7a6cc721 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Tue, 7 Jul 2026 16:09:46 +0300 Subject: [PATCH] fix: report correct selection in Android onChange event during autofill On Android, filling a TextInput via platform autofill emitted an onChange event whose nativeEvent.selection was {start: 0, end: 0} regardless of the inserted text length. TextView.autofill() first calls setText(), which notifies the TextWatchers (where ReactTextInputTextWatcher reads the selection for the topChange event) while the cursor still sits at its pre-autofill position, and only afterwards moves the cursor to the end of the new text. Track the in-progress autofill on ReactEditText and report the selection at the end of the new text, matching where the framework places the cursor immediately after. Fixes #57458 --- .../react/views/textinput/ReactEditText.kt | 18 ++++ .../textinput/ReactTextInputTextWatcher.kt | 12 ++- .../ReactTextInputTextWatcherTest.kt | 101 ++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputTextWatcherTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt index 0415dca1126b..5b7847e6e836 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt @@ -37,9 +37,11 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityNodeInfo +import android.view.autofill.AutofillValue import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager +import androidx.annotation.RequiresApi import androidx.appcompat.widget.AppCompatEditText import androidx.core.graphics.withTranslation import androidx.core.util.Predicate @@ -147,6 +149,12 @@ public open class ReactEditText public constructor(context: Context) : AppCompat internal var disableTextDiffing: Boolean = false protected var isSettingTextFromState: Boolean = false + // TextView.autofill() sets the new text (notifying the TextWatchers) before it moves the + // cursor to the end of the text, so watchers reading the selection while an autofill is in + // progress would see a stale position. + internal var isBeingAutofilled: Boolean = false + private set + private var eventDispatcher: EventDispatcher? = null private var textWatcherDelegator: TextWatcherDelegator? = null @@ -339,6 +347,16 @@ public open class ReactEditText public constructor(context: Context) : AppCompat // We don't call super.setLineHeight() because LineHeight is fully managed by ReactNative } + @RequiresApi(Build.VERSION_CODES.O) + override fun autofill(value: AutofillValue) { + isBeingAutofilled = true + try { + super.autofill(value) + } finally { + isBeingAutofilled = false + } + } + override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) { super.onScrollChanged(horiz, vert, oldHoriz, oldVert) scrollWatcher?.onScrollChanged(horiz, vert, oldHoriz, oldVert) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputTextWatcher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputTextWatcher.kt index 22e20f799c21..076bb1d43813 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputTextWatcher.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputTextWatcher.kt @@ -18,8 +18,9 @@ import com.facebook.react.uimanager.events.EventDispatcher internal class ReactTextInputTextWatcher( reactContext: ReactContext, private val editText: ReactEditText, + private val eventDispatcher: EventDispatcher? = + UIManagerHelper.getEventDispatcher(reactContext), ) : TextWatcher { - private val eventDispatcher: EventDispatcher? = UIManagerHelper.getEventDispatcher(reactContext) private val surfaceId = UIManagerHelper.getSurfaceId(reactContext) private var previousText: String? = null @@ -55,6 +56,11 @@ internal class ReactTextInputTextWatcher( stateWrapper.updateState(newStateData) } + // While an autofill is in progress the cursor still sits at its pre-autofill position; + // TextView.autofill() moves it to the end of the text only after this watcher has run. + val selectionStart = if (editText.isBeingAutofilled) s.length else editText.selectionStart + val selectionEnd = if (editText.isBeingAutofilled) s.length else editText.selectionEnd + // The event that contains the event counter and updates it must be sent first. eventDispatcher?.dispatchEvent( ReactTextChangedEvent( @@ -62,8 +68,8 @@ internal class ReactTextInputTextWatcher( editText.id, s.toString(), editText.incrementAndGetEventCounter(), - editText.selectionStart, - editText.selectionEnd, + selectionStart, + selectionEnd, ) ) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputTextWatcherTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputTextWatcherTest.kt new file mode 100644 index 000000000000..deb9672f0d18 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputTextWatcherTest.kt @@ -0,0 +1,101 @@ +/* + * 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. + */ + +// TODO T207169925: Migrate CatalystInstance to Reacthost and remove the Suppress("DEPRECATION") +// annotation +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.textinput + +import android.util.DisplayMetrics +import android.view.autofill.AutofillValue +import androidx.core.content.res.ResourcesCompat.ID_NULL +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.CatalystInstance +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.bridge.WritableMap +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.testutils.fakes.FakeEventDispatcher +import com.facebook.testutils.shadows.ShadowArguments +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * Verify the selection reported in {@link ReactTextChangedEvent}s emitted by {@link + * ReactTextInputTextWatcher} + */ +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowArguments::class]) +class ReactTextInputTextWatcherTest { + + private lateinit var context: BridgeReactContext + private lateinit var catalystInstanceMock: CatalystInstance + private lateinit var themedContext: ThemedReactContext + private lateinit var manager: ReactTextInputManager + private lateinit var view: ReactEditText + private lateinit var eventDispatcher: FakeEventDispatcher + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + context = BridgeReactContext(RuntimeEnvironment.getApplication()) + catalystInstanceMock = createMockCatalystInstance() + context.initializeWithInstance(catalystInstanceMock) + themedContext = ThemedReactContext(context, context.baseContext, null, ID_NULL) + manager = ReactTextInputManager() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + view = manager.createViewInstance(themedContext) + eventDispatcher = FakeEventDispatcher() + view.addTextChangedListener(ReactTextInputTextWatcher(themedContext, view, eventDispatcher)) + } + + @Test + fun testAutofillReportsSelectionAtEndOfText() { + val autofilledText = "+15551234567" + + view.autofill(AutofillValue.forText(autofilledText)) + + // The framework moves the cursor to the end of the text after autofilling + assertThat(view.selectionStart).isEqualTo(autofilledText.length) + assertThat(view.selectionEnd).isEqualTo(autofilledText.length) + + val eventData = lastTextChangedEventData() + assertThat(eventData.getString("text")).isEqualTo(autofilledText) + val selection = checkNotNull(eventData.getMap("selection")) + assertThat(selection.getInt("start")).isEqualTo(autofilledText.length) + assertThat(selection.getInt("end")).isEqualTo(autofilledText.length) + } + + @Test + fun testTextInsertedAtCursorReportsSelectionAfterInsertedText() { + view.setText("+1555123") + view.setSelection(8) + + // Mimics pasting/typing "4567" at the cursor position + checkNotNull(view.text).insert(8, "4567") + + val eventData = lastTextChangedEventData() + assertThat(eventData.getString("text")).isEqualTo("+15551234567") + val selection = checkNotNull(eventData.getMap("selection")) + assertThat(selection.getInt("start")).isEqualTo(12) + assertThat(selection.getInt("end")).isEqualTo(12) + } + + private fun lastTextChangedEventData(): WritableMap { + val events = + eventDispatcher.getRecordedDispatchedEvents().filterIsInstance() + assertThat(events).isNotEmpty + return checkNotNull(events.last().internal_getEventData()) + } +}