Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions packages/react-native/Libraries/Utilities/BackHandler.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ type BackPressEventName = 'backPress' | 'hardwareBackPress';
type BackPressHandler = (event: HardwareBackPressEvent) => ?boolean;

const _backPressSubscriptions: Array<BackPressHandler> = [];
let nativeNavigationEnabled = false;
const nativeNavigationFallbacks: Set<BackPressHandler> = new Set();

function updateNativeNavigation(): void {
NativeDeviceEventManager?.setNativeNavigationEnabled(
nativeNavigationEnabled,
_backPressSubscriptions.some(
handler => !nativeNavigationFallbacks.has(handler),
),
);
}

RCTDeviceEventEmitter.addListener(DEVICE_BACK_EVENT, function (nativeEvent) {
const options = {};
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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();
}
},
};
Expand Down
10 changes: 9 additions & 1 deletion packages/react-native/Libraries/Utilities/BackHandler.ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
...
},
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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],
]);
});
});
20 changes: 19 additions & 1 deletion packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
public final class com/facebook/react/BackNavigationCoordinator {
public fun <init> ()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 <init> ()V
public fun createNativeModules (Lcom/facebook/react/bridge/ReactApplicationContext;)Ljava/util/List;
Expand Down Expand Up @@ -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 <init> ()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;
Expand Down Expand Up @@ -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 <init> (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 {
Expand Down
Loading
Loading