From 3d3cdc539fe860ebabbad87609601aa134504395 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 16:31:09 +0300 Subject: [PATCH] Return no next view when a ScrollView's content view is gone findNextFocusableView read host.getChildAt(0).id without checking for the child, so a focus search on a scroll view whose content view had already been removed threw a NullPointerException, as #53046 reports for a screen navigating back with a focused TextInput. It now returns null, which leaves the platform's own answer in place. --- .../views/scroll/ReactScrollViewHelper.kt | 7 +- .../ReactScrollViewHelperFocusSearchTest.kt | 91 +++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt index e6e0660d174b..67ef11495446 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt @@ -577,6 +577,8 @@ public object ReactScrollViewHelper { return null } + val contentView = host.getChildAt(0) ?: return null + val uimanager = UIManagerHelper.getUIManager(host.context as ReactContext, UIManagerType.FABRIC) ?: return null @@ -586,9 +588,8 @@ public object ReactScrollViewHelper { ?: return null val ancestorIdList = - uimanager - .getRelativeAncestorList(host.getChildAt(0).id, nextFocusableViewId) - ?.toMutableSet() ?: return null + uimanager.getRelativeAncestorList(contentView.id, nextFocusableViewId)?.toMutableSet() + ?: return null ancestorIdList.add(nextFocusableViewId) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt new file mode 100644 index 000000000000..14103307c3a6 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt @@ -0,0 +1,91 @@ +/* + * 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. + */ + +// The deprecated APIs exercised here remain public and required by this Robolectric setup. +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.scroll + +import android.view.View +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.fabric.FabricUIManager +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.common.UIManagerType +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.soloader.SoLoader +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** Verifies the shadow-tree focus search on a scroll view whose content view is gone. */ +@RunWith(RobolectricTestRunner::class) +class ReactScrollViewHelperFocusSearchTest { + + private lateinit var themedContext: ThemedReactContext + private lateinit var uiManagerHelperMock: MockedStatic + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + SoLoader.setInTestMode() + val context = BridgeReactContext(RuntimeEnvironment.getApplication()) + context.initializeWithInstance(createMockCatalystInstance()) + themedContext = ThemedReactContext(context, context, null, -1) + + val fabricUIManager: FabricUIManager = mock() + whenever(fabricUIManager.findNextFocusableElement(any(), any(), any())).thenReturn(NEXT_TAG) + + uiManagerHelperMock = mockStatic(UIManagerHelper::class.java) + uiManagerHelperMock + .`when` { UIManagerHelper.getEventDispatcher(anyOrNull()) } + .thenReturn(mock()) + uiManagerHelperMock + .`when` { UIManagerHelper.getUIManager(any(), eq(UIManagerType.FABRIC)) } + .thenReturn(fabricUIManager) + } + + @After + fun teardown() { + uiManagerHelperMock.close() + } + + @Test + fun testScrollViewWithoutContentViewHasNoNextFocusableView() { + val scrollView = ReactScrollView(themedContext) + val focused = View(themedContext) + + assertThat(ReactScrollViewHelper.findNextFocusableView(scrollView, focused, View.FOCUS_DOWN)) + .isNull() + } + + @Test + fun testHorizontalScrollViewWithoutContentViewHasNoNextFocusableView() { + val scrollView = ReactHorizontalScrollView(themedContext) + val focused = View(themedContext) + + assertThat(ReactScrollViewHelper.findNextFocusableView(scrollView, focused, View.FOCUS_RIGHT)) + .isNull() + } + + private companion object { + const val NEXT_TAG = 42 + } +}