diff --git a/packages/react-native/Libraries/Utilities/BackHandler.android.js b/packages/react-native/Libraries/Utilities/BackHandler.android.js index 597f2eacc59a..e8396cae13c1 100644 --- a/packages/react-native/Libraries/Utilities/BackHandler.android.js +++ b/packages/react-native/Libraries/Utilities/BackHandler.android.js @@ -19,6 +19,17 @@ type BackPressEventName = 'backPress' | 'hardwareBackPress'; type BackPressHandler = (event: HardwareBackPressEvent) => ?boolean; const _backPressSubscriptions: Array = []; +let nativeNavigationEnabled = false; +const nativeNavigationFallbacks: Set = new Set(); + +function updateNativeNavigation(): void { + NativeDeviceEventManager?.setNativeNavigationEnabled( + nativeNavigationEnabled, + _backPressSubscriptions.some( + handler => !nativeNavigationFallbacks.has(handler), + ), + ); +} RCTDeviceEventEmitter.addListener(DEVICE_BACK_EVENT, function (nativeEvent) { const options = {}; @@ -47,12 +58,28 @@ RCTDeviceEventEmitter.addListener(DEVICE_BACK_EVENT, function (nativeEvent) { */ type TBackHandler = { readonly exitApp: () => void, + readonly setNativeNavigationEnabled: (enabled: boolean) => void, readonly addEventListener: ( eventName: BackPressEventName, handler: BackPressHandler, - ) => {remove: () => void, ...}, + options?: {nativeNavigationFallback?: boolean, ...}, + ) => { + remove: () => void, + setNativeNavigationFallback: (enabled: boolean) => void, + ... + }, }; const BackHandler: TBackHandler = { + /** + * Opt into native predictive Back ownership on Android. With no registered + * JavaScript handlers, native navigators or the system own Back. Any handler + * conservatively restores JavaScript ownership, regardless of its return value. + * Configure this once at the navigation root, not per screen. + */ + setNativeNavigationEnabled(enabled: boolean): void { + nativeNavigationEnabled = enabled; + updateNativeNavigation(); + }, /** * Programmatically exit the app. */ @@ -67,19 +94,42 @@ const BackHandler: TBackHandler = { /** * Listen for the `hardwareBackPress` event. The handler should return `true` * to prevent the event from bubbling to earlier registered listeners. + * + * Navigation libraries may mark their fallback as nativeNavigationFallback + * only while an equivalent native owner is ready. The fallback remains in the + * JS chain, so an unhandled overlay event still navigates. Use the returned + * setNativeNavigationFallback method to change ownership without changing + * handler ordering. Ordinary application handlers must keep the default. */ addEventListener: function ( eventName: BackPressEventName, handler: BackPressHandler, - ): {remove: () => void, ...} { + options?: {nativeNavigationFallback?: boolean, ...}, + ): { + remove: () => void, + setNativeNavigationFallback: (enabled: boolean) => void, + ... + } { if (_backPressSubscriptions.indexOf(handler) === -1) { _backPressSubscriptions.push(handler); + if (options?.nativeNavigationFallback === true) { + nativeNavigationFallbacks.add(handler); + } + if (nativeNavigationEnabled) updateNativeNavigation(); } return { + setNativeNavigationFallback: (enabled: boolean): void => { + if (_backPressSubscriptions.indexOf(handler) === -1) return; + if (enabled) nativeNavigationFallbacks.add(handler); + else nativeNavigationFallbacks.delete(handler); + if (nativeNavigationEnabled) updateNativeNavigation(); + }, remove: (): void => { const index = _backPressSubscriptions.indexOf(handler); if (index !== -1) { _backPressSubscriptions.splice(index, 1); + nativeNavigationFallbacks.delete(handler); + if (nativeNavigationEnabled) updateNativeNavigation(); } }, }; diff --git a/packages/react-native/Libraries/Utilities/BackHandler.ios.js b/packages/react-native/Libraries/Utilities/BackHandler.ios.js index e9bb65f193bb..17508df364cb 100644 --- a/packages/react-native/Libraries/Utilities/BackHandler.ios.js +++ b/packages/react-native/Libraries/Utilities/BackHandler.ios.js @@ -17,17 +17,25 @@ function emptyFunction(): void {} type TBackHandler = { exitApp(): void, + setNativeNavigationEnabled(enabled: boolean): void, addEventListener( eventName: BackPressEventName, handler: BackPressHandler, - ): {remove: () => void, ...}, + options?: {nativeNavigationFallback?: boolean, ...}, + ): { + remove: () => void, + setNativeNavigationFallback: (enabled: boolean) => void, + ... + }, }; const BackHandler: TBackHandler = { exitApp: emptyFunction, + setNativeNavigationEnabled: emptyFunction, addEventListener(_eventName: BackPressEventName, _handler: BackPressHandler) { return { remove: emptyFunction, + setNativeNavigationFallback: emptyFunction, }; }, }; diff --git a/packages/react-native/Libraries/Utilities/BackHandler.js.flow b/packages/react-native/Libraries/Utilities/BackHandler.js.flow index 4c1debea88b0..eb4be79d03a4 100644 --- a/packages/react-native/Libraries/Utilities/BackHandler.js.flow +++ b/packages/react-native/Libraries/Utilities/BackHandler.js.flow @@ -24,10 +24,16 @@ export interface HardwareBackPressEvent { type TBackHandler = { exitApp(): void, + setNativeNavigationEnabled(enabled: boolean): void, addEventListener( eventName: BackPressEventName, handler: (event: HardwareBackPressEvent) => ?boolean, - ): {remove: () => void, ...}, + options?: {nativeNavigationFallback?: boolean, ...}, + ): { + remove: () => void, + setNativeNavigationFallback: (enabled: boolean) => void, + ... + }, }; /** diff --git a/packages/react-native/Libraries/Utilities/__tests__/BackHandlerNativeOwnership-test.js b/packages/react-native/Libraries/Utilities/__tests__/BackHandlerNativeOwnership-test.js new file mode 100644 index 000000000000..b4b0cc61a6ee --- /dev/null +++ b/packages/react-native/Libraries/Utilities/__tests__/BackHandlerNativeOwnership-test.js @@ -0,0 +1,150 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * Licensed under the MIT license in the root directory of this source tree. + * @flow strict-local + * @format + */ + +jest.mock('../../NativeModules/specs/NativeDeviceEventManager', () => ({ + setNativeNavigationEnabled: jest.fn(), + invokeDefaultBackPressHandler: jest.fn(), +})); + +describe('native Back ownership', () => { + let BackHandler; + let native; + beforeEach(() => { + jest.resetModules(); + BackHandler = require('../BackHandler.android').default; + native = require('../../NativeModules/specs/NativeDeviceEventManager'); + }); + + it('preserves default ownership without native calls', () => { + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + () => false, + ); + subscription.remove(); + expect(native.setNativeNavigationEnabled).not.toHaveBeenCalled(); + }); + + it('includes already registered handlers atomically when opting in', () => { + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + () => false, + ); + BackHandler.setNativeNavigationEnabled(true); + expect(native.setNativeNavigationEnabled.mock.calls).toEqual([ + [true, true], + ]); + subscription.remove(); + expect(native.setNativeNavigationEnabled).toHaveBeenLastCalledWith( + true, + false, + ); + }); + + it('retains JS ownership until the last unique handler is removed', () => { + BackHandler.setNativeNavigationEnabled(true); + const handler = () => false; + const first = BackHandler.addEventListener('hardwareBackPress', handler); + const duplicate = BackHandler.addEventListener( + 'hardwareBackPress', + handler, + ); + const second = BackHandler.addEventListener( + 'hardwareBackPress', + () => true, + ); + first.remove(); + duplicate.remove(); + expect(native.setNativeNavigationEnabled).toHaveBeenLastCalledWith( + true, + true, + ); + second.remove(); + expect(native.setNativeNavigationEnabled).toHaveBeenLastCalledWith( + true, + false, + ); + expect(native.setNativeNavigationEnabled.mock.calls).toHaveLength(5); + }); + + it('keeps fallback ordering stable when ownership changes', () => { + const order = []; + const fallback = BackHandler.addEventListener('hardwareBackPress', () => { + order.push('router'); + return true; + }); + let consume = false; + BackHandler.addEventListener('hardwareBackPress', () => { + order.push('overlay'); + return consume; + }); + BackHandler.setNativeNavigationEnabled(true); + fallback.setNativeNavigationFallback(true); + expect(native.setNativeNavigationEnabled).toHaveBeenLastCalledWith( + true, + true, + ); + const emitter = require('../../EventEmitter/RCTDeviceEventEmitter').default; + emitter.emit('hardwareBackPress'); + expect(order).toEqual(['overlay', 'router']); + order.length = 0; + consume = true; + fallback.setNativeNavigationFallback(false); + fallback.setNativeNavigationFallback(true); + emitter.emit('hardwareBackPress'); + expect(order).toEqual(['overlay']); + }); + + it('uses the native default when JS and router fallback both decline', () => { + BackHandler.setNativeNavigationEnabled(true); + BackHandler.addEventListener('hardwareBackPress', () => false, { + nativeNavigationFallback: true, + }); + BackHandler.addEventListener('hardwareBackPress', () => false); + require('../../EventEmitter/RCTDeviceEventEmitter').default.emit( + 'hardwareBackPress', + ); + expect(native.invokeDefaultBackPressHandler).toHaveBeenCalledTimes(1); + }); + + it('delegated fallback alone does not block native preview', () => { + BackHandler.setNativeNavigationEnabled(true); + const fallback = BackHandler.addEventListener( + 'hardwareBackPress', + () => true, + {nativeNavigationFallback: true}, + ); + expect(native.setNativeNavigationEnabled).toHaveBeenLastCalledWith( + true, + false, + ); + fallback.setNativeNavigationFallback(false); + expect(native.setNativeNavigationEnabled).toHaveBeenLastCalledWith( + true, + true, + ); + fallback.remove(); + fallback.setNativeNavigationFallback(false); + expect(native.setNativeNavigationEnabled).toHaveBeenLastCalledWith( + true, + false, + ); + }); + + it('restores legacy mode and stops reporting subscription changes', () => { + BackHandler.setNativeNavigationEnabled(true); + BackHandler.setNativeNavigationEnabled(false); + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + () => true, + ); + subscription.remove(); + expect(native.setNativeNavigationEnabled.mock.calls).toEqual([ + [true, false], + [false, false], + ]); + }); +}); diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index a5fa388742a7..95af13e2f0ca 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -1,3 +1,15 @@ +public final class com/facebook/react/BackNavigationCoordinator { + public fun ()V + public fun addListener (Ljava/lang/Runnable;)V + public fun hasJavaScriptHandlers ()Z + public fun isJavaScriptEnabled ()Z + public fun isNativeNavigationEnabled ()Z + public fun release (Ljava/lang/Object;)V + public fun removeListener (Ljava/lang/Runnable;)V + public fun update (Ljava/lang/Object;ZZ)V + public fun withNativeFallback (Ljava/lang/Runnable;)V +} + public abstract class com/facebook/react/BaseReactPackage : com/facebook/react/ReactPackage { public fun ()V public fun createNativeModules (Lcom/facebook/react/bridge/ReactApplicationContext;)Ljava/util/List; @@ -61,6 +73,7 @@ public final class com/facebook/react/NativeModuleRegistryBuilder { public abstract class com/facebook/react/ReactActivity : androidx/appcompat/app/AppCompatActivity, com/facebook/react/modules/core/DefaultHardwareBackBtnHandler, com/facebook/react/modules/core/PermissionAwareActivity { protected fun ()V protected fun createReactActivityDelegate ()Lcom/facebook/react/ReactActivityDelegate; + public final fun getBackNavigationCoordinator ()Lcom/facebook/react/BackNavigationCoordinator; protected fun getMainComponentName ()Ljava/lang/String; public fun getReactActivityDelegate ()Lcom/facebook/react/ReactActivityDelegate; public fun getReactDelegate ()Lcom/facebook/react/ReactDelegate; @@ -2528,13 +2541,18 @@ public abstract interface class com/facebook/react/modules/core/DefaultHardwareB public abstract fun invokeDefaultOnBackPressed ()V } -public class com/facebook/react/modules/core/DeviceEventManagerModule : com/facebook/fbreact/specs/NativeDeviceEventManagerSpec { +public class com/facebook/react/modules/core/DeviceEventManagerModule : com/facebook/fbreact/specs/NativeDeviceEventManagerSpec, com/facebook/react/bridge/LifecycleEventListener { public static final field Companion Lcom/facebook/react/modules/core/DeviceEventManagerModule$Companion; public static final field NAME Ljava/lang/String; public fun (Lcom/facebook/react/bridge/ReactApplicationContext;Lcom/facebook/react/modules/core/DefaultHardwareBackBtnHandler;)V public fun emitHardwareBackPressed ()V public fun emitNewIntentReceived (Landroid/net/Uri;)V + public fun invalidate ()V public fun invokeDefaultBackPressHandler ()V + public fun onHostDestroy ()V + public fun onHostPause ()V + public fun onHostResume ()V + public fun setNativeNavigationEnabled (ZZ)V } public final class com/facebook/react/modules/core/DeviceEventManagerModule$Companion { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/BackNavigationCoordinator.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/BackNavigationCoordinator.java new file mode 100644 index 000000000000..945dc99a34d9 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/BackNavigationCoordinator.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * Licensed under the MIT license in the root directory of this source tree. + */ +package com.facebook.react; + +import androidx.annotation.UiThread; +import java.util.LinkedHashSet; +import java.util.Set; + +/** Activity-scoped ownership shared by native navigators and JavaScript BackHandler. */ +@UiThread +public final class BackNavigationCoordinator { + private int nativeFallbackDepth; + private Object owner; + private boolean nativeNavigationEnabled; + private boolean javaScriptHandlers; + private final Set listeners = new LinkedHashSet<>(); + + public boolean isNativeNavigationEnabled() { + return nativeNavigationEnabled; + } + + public boolean hasJavaScriptHandlers() { + return javaScriptHandlers; + } + + public boolean isJavaScriptEnabled() { + return nativeFallbackDepth == 0 && (!nativeNavigationEnabled || javaScriptHandlers); + } + + /** Dispatches an unhandled JS Back to native owners without re-entering JS. */ + public void withNativeFallback(Runnable dispatch) { + nativeFallbackDepth++; + try { + notifyListeners(); + dispatch.run(); + } finally { + nativeFallbackDepth--; + notifyListeners(); + } + } + + private void notifyListeners() { + for (Runnable listener : listeners.toArray(new Runnable[0])) listener.run(); + } + + public void addListener(Runnable listener) { + listeners.add(listener); + } + + public void removeListener(Runnable listener) { + listeners.remove(listener); + } + + public void release(Object previousOwner) { + if (owner != previousOwner) return; + owner = null; + updateState(false, false); + } + + public void update(Object nextOwner, boolean enabled, boolean hasHandlers) { + owner = nextOwner; + updateState(enabled, hasHandlers); + } + + private void updateState(boolean enabled, boolean hasHandlers) { + if (nativeNavigationEnabled == enabled && javaScriptHandlers == hasHandlers) return; + nativeNavigationEnabled = enabled; + javaScriptHandlers = hasHandlers; + notifyListeners(); + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivity.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivity.java index b375d1859dbc..8721b2836c1b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivity.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivity.java @@ -9,6 +9,7 @@ import android.content.Intent; import android.content.res.Configuration; +import android.os.Build; import android.os.Bundle; import android.view.KeyEvent; import androidx.activity.OnBackPressedCallback; @@ -25,6 +26,23 @@ public abstract class ReactActivity extends AppCompatActivity implements DefaultHardwareBackBtnHandler, PermissionAwareActivity { private final ReactActivityDelegate mDelegate; + private final BackNavigationCoordinator mBackNavigationCoordinator = + new BackNavigationCoordinator(); + private boolean mBackCallbackRegistered; + + public final BackNavigationCoordinator getBackNavigationCoordinator() { + return mBackNavigationCoordinator; + } + + private void updateBackOwnership() { + if (!mBackCallbackRegistered + && Build.VERSION.SDK_INT >= 33 + && mBackNavigationCoordinator.isNativeNavigationEnabled()) { + getOnBackPressedDispatcher().addCallback(this, mBackPressedCallback); + mBackCallbackRegistered = true; + } + mBackPressedCallback.setEnabled(mBackNavigationCoordinator.isJavaScriptEnabled()); + } // Due to enforced predictive back on targetSdk 36, 'onBackPressed()' is disabled by default. // Using a workaround to trigger it manually. @@ -34,7 +52,7 @@ public abstract class ReactActivity extends AppCompatActivity public void handleOnBackPressed() { setEnabled(false); onBackPressed(); - setEnabled(true); + updateBackOwnership(); } }; @@ -60,9 +78,12 @@ protected ReactActivityDelegate createReactActivityDelegate() { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mDelegate.onCreate(savedInstanceState); - if (AndroidVersion.isAtLeastTargetSdk36(this)) { + if (!mBackCallbackRegistered && AndroidVersion.isAtLeastTargetSdk36(this)) { getOnBackPressedDispatcher().addCallback(this, mBackPressedCallback); + mBackCallbackRegistered = true; } + mBackNavigationCoordinator.addListener(this::updateBackOwnership); + updateBackOwnership(); } @Override @@ -124,10 +145,14 @@ public void invokeDefaultOnBackPressed() { // Disabling callback so the fallback logic (finish activity) can run // as super.onBackPressed() will call all enabled callbacks in the dispatcher. mBackPressedCallback.setEnabled(false); - super.onBackPressed(); + if (mBackNavigationCoordinator.isNativeNavigationEnabled()) { + mBackNavigationCoordinator.withNativeFallback(() -> super.onBackPressed()); + } else { + super.onBackPressed(); + } // Re-enable callback to ensure custom back handling works after activity resume // Without this, the callback remains disabled when the app returns from background - mBackPressedCallback.setEnabled(true); + updateBackOwnership(); } @Override diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/DeviceEventManagerModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/DeviceEventManagerModule.kt index 9ee720cf8bbc..a30bd70e5764 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/DeviceEventManagerModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/DeviceEventManagerModule.kt @@ -10,7 +10,9 @@ package com.facebook.react.modules.core import android.net.Uri import com.facebook.fbreact.specs.NativeDeviceEventManagerSpec import com.facebook.proguard.annotations.DoNotStripAny +import com.facebook.react.ReactActivity import com.facebook.react.bridge.JavaScriptModule +import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.bridge.buildReadableMap @@ -19,9 +21,58 @@ import com.facebook.react.module.annotations.ReactModule /** Native module that handles device hardware events like hardware back presses. */ @ReactModule(name = NativeDeviceEventManagerSpec.NAME) public open class DeviceEventManagerModule( - reactContext: ReactApplicationContext?, - backBtnHandler: DefaultHardwareBackBtnHandler?, -) : NativeDeviceEventManagerSpec(reactContext) { + reactContext: ReactApplicationContext?, + backBtnHandler: DefaultHardwareBackBtnHandler?, +) : NativeDeviceEventManagerSpec(reactContext), LifecycleEventListener { + @Volatile private var ownershipInvalidated = false + private val ownershipToken = Any() + private var coordinator: com.facebook.react.BackNavigationCoordinator? = null + private var nativeNavigationEnabled = false + private var hasJavaScriptHandlers = false + + init { + reactContext?.addLifecycleEventListener(this) + } + + private fun updateBackOwnership() { + if (ownershipInvalidated) return + val next = + (reactApplicationContext.currentActivity as? ReactActivity)?.backNavigationCoordinator + if (coordinator !== next) coordinator?.release(ownershipToken) + coordinator = next + next?.update(ownershipToken, nativeNavigationEnabled, hasJavaScriptHandlers) + } + + override fun setNativeNavigationEnabled(enabled: Boolean, hasHandlers: Boolean) { + reactApplicationContext.runOnUiQueueThread { + if (ownershipInvalidated) return@runOnUiQueueThread + nativeNavigationEnabled = enabled + hasJavaScriptHandlers = hasHandlers + updateBackOwnership() + } + } + + override fun onHostResume() { + updateBackOwnership() + } + + override fun onHostPause(): Unit = Unit + + override fun onHostDestroy() { + coordinator?.release(ownershipToken) + coordinator = null + } + + override fun invalidate() { + ownershipInvalidated = true + reactApplicationContext.removeLifecycleEventListener(this) + reactApplicationContext.runOnUiQueueThread { + coordinator?.release(ownershipToken) + coordinator = null + } + super.invalidate() + } + @DoNotStripAny public fun interface RCTDeviceEventEmitter : JavaScriptModule { public fun emit(eventName: String, data: Any?) @@ -35,7 +86,7 @@ public open class DeviceEventManagerModule( /** Sends an event to the JS instance that the hardware back has been pressed. */ public open fun emitHardwareBackPressed() { val reactApplicationContext: ReactApplicationContext? = - getReactApplicationContextIfActiveOrWarn() + getReactApplicationContextIfActiveOrWarn() val map = buildReadableMap { put("timeStamp", System.nanoTime() / 1_000_000.0) } reactApplicationContext?.emitDeviceEvent("hardwareBackPress", map) } @@ -43,7 +94,7 @@ public open class DeviceEventManagerModule( /** Sends an event to the JS instance that a new intent was received. */ public open fun emitNewIntentReceived(uri: Uri) { val reactApplicationContext: ReactApplicationContext? = - getReactApplicationContextIfActiveOrWarn() + getReactApplicationContextIfActiveOrWarn() val map = buildReadableMap { put("url", uri.toString()) } reactApplicationContext?.emitDeviceEvent("url", map) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/BackNavigationCoordinatorTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/BackNavigationCoordinatorTest.kt new file mode 100644 index 000000000000..6f91a4eebb1c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/BackNavigationCoordinatorTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * Licensed under the MIT license in the root directory of this source tree. + */ +package com.facebook.react + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class BackNavigationCoordinatorTest { + @Test + fun nativeFallbackSuppressesJSAndRestoresOwnershipEvenOnThrow() { + val coordinator = BackNavigationCoordinator() + coordinator.update(Any(), true, true) + val states = mutableListOf() + coordinator.addListener { states.add(coordinator.isJavaScriptEnabled) } + try { + coordinator.withNativeFallback { + assertThat(coordinator.isJavaScriptEnabled).isFalse() + throw IllegalStateException("test") + } + } catch (expected: IllegalStateException) { + assertThat(expected.message).isEqualTo("test") + } + assertThat(states).containsExactly(false, true) + assertThat(coordinator.isJavaScriptEnabled).isTrue() + } + + @Test + fun ownershipTransitionsPreserveLegacyUntilExplicitOptIn() { + val coordinator = BackNavigationCoordinator() + val owner = Any() + assertThat(coordinator.isJavaScriptEnabled).isTrue() + coordinator.update(owner, true, false) + assertThat(coordinator.isJavaScriptEnabled).isFalse() + coordinator.update(owner, true, true) + assertThat(coordinator.isJavaScriptEnabled).isTrue() + coordinator.update(owner, false, false) + assertThat(coordinator.isJavaScriptEnabled).isTrue() + } + + @Test + fun staleContextTeardownCannotReleaseCurrentOwner() { + val coordinator = BackNavigationCoordinator() + val old = Any() + val current = Any() + coordinator.update(old, true, false) + coordinator.update(current, true, true) + coordinator.release(old) + assertThat(coordinator.isNativeNavigationEnabled).isTrue() + assertThat(coordinator.hasJavaScriptHandlers()).isTrue() + coordinator.release(current) + assertThat(coordinator.isNativeNavigationEnabled).isFalse() + } + + @Test + fun removedListenersDoNotObserveLaterTransitions() { + val coordinator = BackNavigationCoordinator() + val owner = Any() + var calls = 0 + lateinit var listener: Runnable + listener = Runnable { + calls++ + coordinator.removeListener(listener) + } + coordinator.addListener(listener) + coordinator.update(owner, true, false) + coordinator.update(owner, true, true) + assertThat(calls).isEqualTo(1) + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/core/DeviceEventManagerOwnershipTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/core/DeviceEventManagerOwnershipTest.kt new file mode 100644 index 000000000000..d6e8f28e87ac --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/core/DeviceEventManagerOwnershipTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * Licensed under the MIT license in the root directory of this source tree. + */ +package com.facebook.react.modules.core + +import com.facebook.react.BackNavigationCoordinator +import com.facebook.react.ReactActivity +import com.facebook.react.bridge.ReactApplicationContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DeviceEventManagerOwnershipTest { + @Test + fun destroyedActivityReleasesOwnershipAndResumeRestoresItOnNewActivity() { + val context = mock() + val oldActivity = mock() + val newActivity = mock() + val oldCoordinator = BackNavigationCoordinator() + val newCoordinator = BackNavigationCoordinator() + whenever(oldActivity.backNavigationCoordinator).thenReturn(oldCoordinator) + whenever(newActivity.backNavigationCoordinator).thenReturn(newCoordinator) + whenever(context.currentActivity).thenReturn(oldActivity) + doAnswer { + it.getArgument(0).run() + null + } + .whenever(context) + .runOnUiQueueThread(any()) + val module = DeviceEventManagerModule(context, null) + module.setNativeNavigationEnabled(true, false) + module.onHostDestroy() + assertThat(oldCoordinator.isNativeNavigationEnabled).isFalse() + whenever(context.currentActivity).thenReturn(newActivity) + module.onHostResume() + assertThat(newCoordinator.isNativeNavigationEnabled).isTrue() + assertThat(newCoordinator.isJavaScriptEnabled).isFalse() + } + + @Test + fun queuedUpdatesCannotReclaimOwnershipAfterInvalidation() { + val context = mock() + val activity = mock() + val coordinator = BackNavigationCoordinator() + whenever(context.currentActivity).thenReturn(activity) + whenever(activity.backNavigationCoordinator).thenReturn(coordinator) + val queue = mutableListOf() + doAnswer { + queue.add(it.getArgument(0)) + null + } + .whenever(context) + .runOnUiQueueThread(any()) + val module = DeviceEventManagerModule(context, null) + module.setNativeNavigationEnabled(true, false) + module.invalidate() + module.setNativeNavigationEnabled(true, false) + queue.forEach { it.run() } + module.onHostResume() + assertThat(coordinator.isNativeNavigationEnabled).isFalse() + } + + @Test + fun oldModuleTeardownCannotResetNewModule() { + val context = mock() + val activity = mock() + val coordinator = BackNavigationCoordinator() + whenever(context.currentActivity).thenReturn(activity) + whenever(activity.backNavigationCoordinator).thenReturn(coordinator) + doAnswer { + it.getArgument(0).run() + null + } + .whenever(context) + .runOnUiQueueThread(any()) + val old = DeviceEventManagerModule(context, null) + old.setNativeNavigationEnabled(true, true) + val current = DeviceEventManagerModule(context, null) + current.setNativeNavigationEnabled(true, false) + old.invalidate() + current.onHostResume() + assertThat(coordinator.isNativeNavigationEnabled).isTrue() + assertThat(coordinator.isJavaScriptEnabled).isFalse() + } +} diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index c0f8309f0ac5..3ae14726f706 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -4857,10 +4857,15 @@ declare type TBackHandler = { addEventListener( eventName: BackPressEventName, handler: (event: HardwareBackPressEvent) => boolean | undefined, + options?: { + nativeNavigationFallback?: boolean + }, ): { remove: () => void + setNativeNavigationFallback: (enabled: boolean) => void } exitApp(): void + setNativeNavigationEnabled(enabled: boolean): void } declare type Text = typeof Text declare type TextBaseProps = { @@ -5801,7 +5806,7 @@ export { AssetDestPathResolver, // 59047424 AssetRegistry, // 6070bb45 AutoCapitalize, // c0e857a0 - BackHandler, // f139fc69 + BackHandler, // 6f624903 BackPressEventName, // 4620fb76 BlurEvent, // d5e68e96 BoxShadowValue, // b679703f diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceEventManager.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceEventManager.js index 4efff76e66a0..f5d65795e931 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceEventManager.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceEventManager.js @@ -14,6 +14,10 @@ import * as TurboModuleRegistry from '../../../../Libraries/TurboModule/TurboMod export interface Spec extends TurboModule { readonly invokeDefaultBackPressHandler: () => void; + readonly setNativeNavigationEnabled: ( + enabled: boolean, + hasJavaScriptHandlers: boolean, + ) => void; } export default TurboModuleRegistry.get('DeviceEventManager') as ?Spec; diff --git a/packages/react-native/types_DEPRECATED/Libraries/Utilities/BackHandler.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Utilities/BackHandler.d.ts index ac2a530a9c05..7df391210d38 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Utilities/BackHandler.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Utilities/BackHandler.d.ts @@ -34,10 +34,14 @@ export interface HardwareBackPressEvent { */ export interface BackHandlerStatic { exitApp(): void; + setNativeNavigationEnabled(enabled: boolean): void; addEventListener( eventName: BackPressEventName, handler: (event: HardwareBackPressEvent) => boolean | null | undefined, - ): NativeEventSubscription; + options?: {nativeNavigationFallback?: boolean}, + ): NativeEventSubscription & { + setNativeNavigationFallback(enabled: boolean): void; + }; } export const BackHandler: BackHandlerStatic;