Skip to content

Commit 03f92c6

Browse files
rubennortemeta-codesync[bot]
authored andcommitted
Add HardwareBackPressEvent with native timestamp (#56295)
Summary: Pull Request resolved: #56295 Changelog: [Android][Added] - Pass event object to `BackHandler` `hardwareBackPress` events to access timeStamp from native event. BackHandler on Android emits the `hardwareBackPress` event with a `null` payload — no timestamp or event data. This means there is no way for handlers to know when the user actually pressed the back button, which prevents accurate performance tracing for back navigations. This diff: - Sends `SystemClock.uptimeMillis()` as `timestamp` in the native `DeviceEventManagerModule.emitHardwareBackPressed()` event payload - Creates a new `HardwareBackPressEvent` class extending the DOM `Event` class, which overrides the `timeStamp` getter to return the native timestamp (falling back to `performance.now()` if unavailable) - Makes the public constructor throw `TypeError("Illegal constructor")` following the `PerformanceEntry` pattern - Updates all BackHandler platform files (Android, iOS, macOS, Windows) and type definitions (Flow, TypeScript) to create and pass the event to handlers - Updates the BackHandler mock to pass events in `mockPressBack` - Adds Fantom integration tests for both `HardwareBackPressEvent` and `BackHandler` Differential Revision: D98941079
1 parent 28bc947 commit 03f92c6

9 files changed

Lines changed: 272 additions & 10 deletions

File tree

packages/react-native/Libraries/Utilities/BackHandler.android.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,26 @@
99
*/
1010

1111
import NativeDeviceEventManager from '../../Libraries/NativeModules/specs/NativeDeviceEventManager';
12+
import {setEventInitTimeStamp} from '../../src/private/webapis/dom/events/internals/EventInternals';
1213
import RCTDeviceEventEmitter from '../EventEmitter/RCTDeviceEventEmitter';
14+
import {HardwareBackPressEvent} from './HardwareBackPressEvent';
1315

1416
const DEVICE_BACK_EVENT = 'hardwareBackPress';
1517

1618
type BackPressEventName = 'backPress' | 'hardwareBackPress';
17-
type BackPressHandler = () => ?boolean;
19+
type BackPressHandler = (event: HardwareBackPressEvent) => ?boolean;
1820

1921
const _backPressSubscriptions: Array<BackPressHandler> = [];
2022

21-
RCTDeviceEventEmitter.addListener(DEVICE_BACK_EVENT, function () {
23+
RCTDeviceEventEmitter.addListener(DEVICE_BACK_EVENT, function (nativeEvent) {
24+
const options = {};
25+
const nativeTimestamp = nativeEvent?.timeStamp;
26+
if (nativeTimestamp != null) {
27+
setEventInitTimeStamp(options, nativeTimestamp);
28+
}
29+
const event = new HardwareBackPressEvent(options);
2230
for (let i = _backPressSubscriptions.length - 1; i >= 0; i--) {
23-
if (_backPressSubscriptions[i]?.()) {
31+
if (_backPressSubscriptions[i]?.(event)) {
2432
return;
2533
}
2634
}

packages/react-native/Libraries/Utilities/BackHandler.d.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ import {NativeEventSubscription} from '../EventEmitter/RCTNativeAppEventEmitter'
1111

1212
export type BackPressEventName = 'hardwareBackPress';
1313

14+
/**
15+
* Event dispatched when the hardware back button is pressed.
16+
* The `timeStamp` property reflects the native timestamp captured
17+
* when the back press was emitted.
18+
*/
19+
export interface HardwareBackPressEvent {
20+
readonly type: string;
21+
readonly timeStamp: number;
22+
}
23+
1424
/**
1525
* Detect hardware back button presses, and programmatically invoke the
1626
* default back button functionality to exit the app if there are no
@@ -26,7 +36,7 @@ export interface BackHandlerStatic {
2636
exitApp(): void;
2737
addEventListener(
2838
eventName: BackPressEventName,
29-
handler: () => boolean | null | undefined,
39+
handler: (event: HardwareBackPressEvent) => boolean | null | undefined,
3040
): NativeEventSubscription;
3141
}
3242

packages/react-native/Libraries/Utilities/BackHandler.ios.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
* @format
99
*/
1010

11+
import type {HardwareBackPressEvent} from './HardwareBackPressEvent';
12+
1113
type BackPressEventName = 'backPress' | 'hardwareBackPress';
12-
type BackPressHandler = () => ?boolean;
14+
type BackPressHandler = (event: HardwareBackPressEvent) => ?boolean;
1315

1416
function emptyFunction(): void {}
1517

packages/react-native/Libraries/Utilities/BackHandler.js.flow

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,17 @@
1010

1111
'use strict';
1212

13+
import type {HardwareBackPressEvent} from './HardwareBackPressEvent';
14+
1315
export type BackPressEventName = 'backPress' | 'hardwareBackPress';
1416

17+
export type {HardwareBackPressEvent} from './HardwareBackPressEvent';
18+
1519
type TBackHandler = {
1620
exitApp(): void,
1721
addEventListener(
1822
eventName: BackPressEventName,
19-
handler: () => ?boolean,
23+
handler: (event: HardwareBackPressEvent) => ?boolean,
2024
): {remove: () => void, ...},
2125
};
2226

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import type {EventInit} from '../../src/private/webapis/dom/events/Event';
12+
13+
import Event from '../../src/private/webapis/dom/events/Event';
14+
15+
/**
16+
* Event dispatched when the hardware back button is pressed on Android.
17+
*/
18+
export class HardwareBackPressEvent extends Event {
19+
constructor(options?: ?EventInit) {
20+
super('hardwareBackPress', options);
21+
}
22+
}

packages/react-native/Libraries/Utilities/__mocks__/BackHandler.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,20 @@
99
*/
1010

1111
import type {BackPressEventName} from '../BackHandler';
12+
import type {HardwareBackPressEvent} from '../HardwareBackPressEvent';
1213

13-
const _backPressSubscriptions = new Set<() => ?boolean>();
14+
import {HardwareBackPressEvent as HardwareBackPressEventClass} from '../HardwareBackPressEvent';
15+
16+
const _backPressSubscriptions = new Set<
17+
(event: HardwareBackPressEvent) => ?boolean,
18+
>();
1419

1520
const BackHandler = {
1621
exitApp: jest.fn() as () => void,
1722

1823
addEventListener: function (
1924
eventName: BackPressEventName,
20-
handler: () => ?boolean,
25+
handler: (event: HardwareBackPressEvent) => ?boolean,
2126
): {remove: () => void, ...} {
2227
_backPressSubscriptions.add(handler);
2328
return {
@@ -28,10 +33,11 @@ const BackHandler = {
2833
},
2934

3035
mockPressBack: function () {
36+
const event = new HardwareBackPressEventClass();
3137
let invokeDefault = true;
3238
const subscriptions = [..._backPressSubscriptions].reverse();
3339
for (let i = 0; i < subscriptions.length; ++i) {
34-
if (subscriptions[i]()) {
40+
if (subscriptions[i](event)) {
3541
invokeDefault = false;
3642
break;
3743
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
12+
13+
import type {HardwareBackPressEvent} from 'react-native/Libraries/Utilities/HardwareBackPressEvent';
14+
15+
import RCTDeviceEventEmitter from 'react-native/Libraries/EventEmitter/RCTDeviceEventEmitter';
16+
import BackHandler from 'react-native/Libraries/Utilities/BackHandler';
17+
import {HardwareBackPressEvent as HardwareBackPressEventClass} from 'react-native/Libraries/Utilities/HardwareBackPressEvent';
18+
19+
describe('BackHandler', () => {
20+
const subscriptions: Array<{remove: () => void, ...}> = [];
21+
22+
afterEach(() => {
23+
for (const sub of subscriptions) {
24+
sub.remove();
25+
}
26+
subscriptions.length = 0;
27+
});
28+
29+
it('calls handlers in reverse order (LIFO)', () => {
30+
const callOrder: Array<string> = [];
31+
const handler1 = (_event: HardwareBackPressEvent) => {
32+
callOrder.push('first');
33+
return false;
34+
};
35+
const handler2 = (_event: HardwareBackPressEvent) => {
36+
callOrder.push('second');
37+
return true;
38+
};
39+
40+
subscriptions.push(
41+
BackHandler.addEventListener('hardwareBackPress', handler1),
42+
);
43+
subscriptions.push(
44+
BackHandler.addEventListener('hardwareBackPress', handler2),
45+
);
46+
47+
RCTDeviceEventEmitter.emit('hardwareBackPress', {timeStamp: 100});
48+
49+
expect(callOrder).toEqual(['second']);
50+
});
51+
52+
it('calls all handlers when none return true', () => {
53+
const callOrder: Array<string> = [];
54+
const handler1 = (_event: HardwareBackPressEvent) => {
55+
callOrder.push('first');
56+
return false;
57+
};
58+
const handler2 = (_event: HardwareBackPressEvent) => {
59+
callOrder.push('second');
60+
return false;
61+
};
62+
63+
subscriptions.push(
64+
BackHandler.addEventListener('hardwareBackPress', handler1),
65+
);
66+
subscriptions.push(
67+
BackHandler.addEventListener('hardwareBackPress', handler2),
68+
);
69+
70+
RCTDeviceEventEmitter.emit('hardwareBackPress', {timeStamp: 100});
71+
72+
expect(callOrder).toEqual(['second', 'first']);
73+
});
74+
75+
it('passes HardwareBackPressEvent to handlers', () => {
76+
let receivedEvent: ?HardwareBackPressEvent = null;
77+
const handler = (event: HardwareBackPressEvent) => {
78+
receivedEvent = event;
79+
return true;
80+
};
81+
82+
subscriptions.push(
83+
BackHandler.addEventListener('hardwareBackPress', handler),
84+
);
85+
86+
RCTDeviceEventEmitter.emit('hardwareBackPress', {timeStamp: 42});
87+
88+
expect(receivedEvent).toBeInstanceOf(HardwareBackPressEventClass);
89+
});
90+
91+
it('event has native timestamp as timeStamp', () => {
92+
let receivedEvent: ?HardwareBackPressEvent = null;
93+
const handler = (event: HardwareBackPressEvent) => {
94+
receivedEvent = event;
95+
return true;
96+
};
97+
98+
subscriptions.push(
99+
BackHandler.addEventListener('hardwareBackPress', handler),
100+
);
101+
102+
RCTDeviceEventEmitter.emit('hardwareBackPress', {timeStamp: 42});
103+
104+
expect(receivedEvent?.timeStamp).toBe(42);
105+
});
106+
107+
it('event falls back to performance.now() when no native timestamp', () => {
108+
let receivedEvent: ?HardwareBackPressEvent = null;
109+
const handler = (event: HardwareBackPressEvent) => {
110+
receivedEvent = event;
111+
return true;
112+
};
113+
114+
subscriptions.push(
115+
BackHandler.addEventListener('hardwareBackPress', handler),
116+
);
117+
118+
const before = performance.now();
119+
RCTDeviceEventEmitter.emit('hardwareBackPress', null);
120+
const after = performance.now();
121+
122+
const timeStamp = receivedEvent?.timeStamp;
123+
expect(timeStamp).not.toBeNull();
124+
if (timeStamp != null) {
125+
expect(timeStamp).toBeGreaterThanOrEqual(before);
126+
expect(timeStamp).toBeLessThanOrEqual(after);
127+
}
128+
});
129+
130+
it('removes handler on subscription.remove()', () => {
131+
let called = false;
132+
const handler = (_event: HardwareBackPressEvent) => {
133+
called = true;
134+
return true;
135+
};
136+
137+
const sub = BackHandler.addEventListener('hardwareBackPress', handler);
138+
sub.remove();
139+
140+
RCTDeviceEventEmitter.emit('hardwareBackPress', {timeStamp: 100});
141+
142+
expect(called).toBe(false);
143+
});
144+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
12+
13+
import {HardwareBackPressEvent} from 'react-native/Libraries/Utilities/HardwareBackPressEvent';
14+
import Event from 'react-native/src/private/webapis/dom/events/Event';
15+
import {setEventInitTimeStamp} from 'react-native/src/private/webapis/dom/events/internals/EventInternals';
16+
17+
describe('HardwareBackPressEvent', () => {
18+
it('extends Event', () => {
19+
const event = new HardwareBackPressEvent();
20+
21+
expect(event).toBeInstanceOf(Event);
22+
expect(event.type).toBe('hardwareBackPress');
23+
expect(event.bubbles).toBe(false);
24+
expect(event.cancelable).toBe(false);
25+
expect(event.composed).toBe(false);
26+
});
27+
28+
it('uses native timestamp as timeStamp when provided via setEventInitTimeStamp', () => {
29+
const options = {};
30+
setEventInitTimeStamp(options, 12345);
31+
const event = new HardwareBackPressEvent(options);
32+
33+
expect(event.timeStamp).toBe(12345);
34+
});
35+
36+
it('falls back to performance.now() when no timestamp is provided', () => {
37+
const before = performance.now();
38+
const event = new HardwareBackPressEvent();
39+
const after = performance.now();
40+
41+
expect(event.timeStamp).toBeGreaterThanOrEqual(before);
42+
expect(event.timeStamp).toBeLessThanOrEqual(after);
43+
});
44+
45+
it('falls back to performance.now() when options is undefined', () => {
46+
const before = performance.now();
47+
const event = new HardwareBackPressEvent(undefined);
48+
const after = performance.now();
49+
50+
expect(event.timeStamp).toBeGreaterThanOrEqual(before);
51+
expect(event.timeStamp).toBeLessThanOrEqual(after);
52+
});
53+
54+
it('does NOT allow changing the timeStamp value after construction', () => {
55+
const options = {};
56+
setEventInitTimeStamp(options, 12345);
57+
const event = new HardwareBackPressEvent(options);
58+
59+
expect(() => {
60+
'use strict';
61+
// $FlowExpectedError[cannot-write]
62+
event.timeStamp = 999;
63+
}).toThrow();
64+
});
65+
});

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/DeviceEventManagerModule.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ public open class DeviceEventManagerModule(
3636
public open fun emitHardwareBackPressed() {
3737
val reactApplicationContext: ReactApplicationContext? =
3838
getReactApplicationContextIfActiveOrWarn()
39-
reactApplicationContext?.emitDeviceEvent("hardwareBackPress", null)
39+
val map = buildReadableMap { put("timeStamp", System.nanoTime() / 1_000_000.0) }
40+
reactApplicationContext?.emitDeviceEvent("hardwareBackPress", map)
4041
}
4142

4243
/** Sends an event to the JS instance that a new intent was received. */

0 commit comments

Comments
 (0)