diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java index 95788059d16..a6188100c52 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java @@ -928,6 +928,7 @@ private class CustomGlobalLayoutListener implements ViewTreeObserver.OnGlobalLay private final Rect mVisibleViewArea; private boolean mKeyboardIsVisible = false; + private int mKeyboardHeight = 0; private int mDeviceRotation = 0; /* package */ CustomGlobalLayoutListener() { @@ -954,13 +955,17 @@ private void checkForKeyboardEvents() { } boolean keyboardIsVisible = rootInsets.isVisible(WindowInsetsCompat.Type.ime()); - if (keyboardIsVisible != mKeyboardIsVisible) { - mKeyboardIsVisible = keyboardIsVisible; - Insets barInsets = rootInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + Insets barInsets = rootInsets.getInsets(WindowInsetsCompat.Type.systemBars()); - if (keyboardIsVisible) { - Insets imeInsets = rootInsets.getInsets(WindowInsetsCompat.Type.ime()); - int height = imeInsets.bottom - barInsets.bottom; + if (keyboardIsVisible) { + Insets imeInsets = rootInsets.getInsets(WindowInsetsCompat.Type.ime()); + int height = imeInsets.bottom - barInsets.bottom; + + // Re-emit on height change while keyboard stays visible (e.g., emoji + // panel toggle); JS consumers cache endCoordinates from keyboardDidShow. + if (!mKeyboardIsVisible || height != mKeyboardHeight) { + mKeyboardIsVisible = true; + mKeyboardHeight = height; ViewGroup.LayoutParams rootLayoutParams = getRootView().getLayoutParams(); Assertions.assertCondition(rootLayoutParams instanceof WindowManager.LayoutParams); @@ -978,15 +983,18 @@ private void checkForKeyboardEvents() { PixelUtil.toDIPFromPixel(mVisibleViewArea.left), PixelUtil.toDIPFromPixel(mVisibleViewArea.width()), PixelUtil.toDIPFromPixel(height))); - } else { - sendEvent( - "keyboardDidHide", - createKeyboardEventPayload( - PixelUtil.toDIPFromPixel(mVisibleViewArea.bottom + barInsets.bottom), - 0, - PixelUtil.toDIPFromPixel(mVisibleViewArea.width()), - 0)); } + } else if (mKeyboardIsVisible) { + mKeyboardIsVisible = false; + mKeyboardHeight = 0; + + sendEvent( + "keyboardDidHide", + createKeyboardEventPayload( + PixelUtil.toDIPFromPixel(mVisibleViewArea.bottom + barInsets.bottom), + 0, + PixelUtil.toDIPFromPixel(mVisibleViewArea.width()), + 0)); } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/RootViewTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/RootViewTest.kt index ff1b35d3365..75099fe2341 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/RootViewTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/RootViewTest.kt @@ -9,9 +9,11 @@ package com.facebook.react +import android.annotation.SuppressLint import android.app.Activity import android.graphics.Insets import android.graphics.Rect +import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowManager import com.facebook.react.bridge.Arguments @@ -107,4 +109,86 @@ class RootViewTest { params.putString("easing", "keyboard") verify(reactContext, times(1)).emitDeviceEvent("keyboardDidShow", params) } + + // Regression test for the keyboard re-emit behavior. Without the + // height-change re-emit in `checkForKeyboardEvents`, JS consumers that + // cache `endCoordinates` (KeyboardAvoidingView, ScrollView, Keyboard.metrics) + // observe stale geometry when the IME height changes (e.g., emoji panel + // toggle) without a visibility transition. + @SuppressLint("NewApi", "DeprecatedClass") + @Test + fun testCheckForKeyboardEventsReEmitsOnHeightChange() { + val instanceManager: ReactInstanceManager = mock() + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + whenever(instanceManager.currentReactContext).thenReturn(reactContext) + + val imeBottom = intArrayOf(370) + val imeVisible = booleanArrayOf(true) + + val rootView: ReactRootView = + object : ReactRootView(activity) { + override fun getWindowVisibleDisplayFrame(outRect: Rect) { + outRect.set(0, 0, 370, 100) + } + + override fun getRootWindowInsets(): WindowInsets = + WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, imeBottom[0])) + .setVisible(WindowInsets.Type.ime(), imeVisible[0]) + .build() + + override fun getLayoutParams(): ViewGroup.LayoutParams = WindowManager.LayoutParams() + } + + rootView.startReactApplication(instanceManager, "") + + // 1) Initial show — keyboardDidShow fires once with height=370. + rootView.simulateCheckForKeyboardForTesting() + verify(reactContext, times(1)).emitDeviceEvent("keyboardDidShow", showParams(370.0)) + + // 2) Idempotent layout pass with same height — must NOT re-emit. + rootView.simulateCheckForKeyboardForTesting() + verify(reactContext, times(1)).emitDeviceEvent("keyboardDidShow", showParams(370.0)) + + // 3) IME height grows (e.g., emoji panel) — must re-emit with new height. + // This is the case the regression silently dropped. + imeBottom[0] = 420 + rootView.simulateCheckForKeyboardForTesting() + verify(reactContext, times(1)).emitDeviceEvent("keyboardDidShow", showParams(420.0)) + + // 4) Hide — keyboardDidHide fires once. + imeVisible[0] = false + rootView.simulateCheckForKeyboardForTesting() + verify(reactContext, times(1)).emitDeviceEvent("keyboardDidHide", hideParams()) + + // 5) Idempotent layout pass with keyboard still hidden — must NOT re-emit. + rootView.simulateCheckForKeyboardForTesting() + verify(reactContext, times(1)).emitDeviceEvent("keyboardDidHide", hideParams()) + } + + private fun showParams(keyboardHeight: Double): com.facebook.react.bridge.WritableMap { + val params = Arguments.createMap() + val endCoordinates = Arguments.createMap() + params.putDouble("duration", 0.0) + endCoordinates.putDouble("width", 370.0) + endCoordinates.putDouble("screenX", 0.0) + endCoordinates.putDouble("height", keyboardHeight) + endCoordinates.putDouble("screenY", 100.0) + params.putMap("endCoordinates", endCoordinates) + params.putString("easing", "keyboard") + return params + } + + private fun hideParams(): com.facebook.react.bridge.WritableMap { + val params = Arguments.createMap() + val endCoordinates = Arguments.createMap() + params.putDouble("duration", 0.0) + endCoordinates.putDouble("width", 370.0) + endCoordinates.putDouble("screenX", 0.0) + endCoordinates.putDouble("height", 0.0) + endCoordinates.putDouble("screenY", 100.0) + params.putMap("endCoordinates", endCoordinates) + params.putString("easing", "keyboard") + return params + } }