Skip to content

Commit 57c82c9

Browse files
rubennortefacebook-github-bot
authored andcommitted
Add HardwareBackPressEvent with native timestamp (facebook#56295)
Summary: 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 de78906 commit 57c82c9

9 files changed

Lines changed: 305 additions & 10 deletions

File tree

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,19 @@
1010

1111
import NativeDeviceEventManager from '../../Libraries/NativeModules/specs/NativeDeviceEventManager';
1212
import RCTDeviceEventEmitter from '../EventEmitter/RCTDeviceEventEmitter';
13+
import {HardwareBackPressEvent} from './HardwareBackPressEvent';
1314

1415
const DEVICE_BACK_EVENT = 'hardwareBackPress';
1516

1617
type BackPressEventName = 'backPress' | 'hardwareBackPress';
17-
type BackPressHandler = () => ?boolean;
18+
type BackPressHandler = (event: HardwareBackPressEvent) => ?boolean;
1819

1920
const _backPressSubscriptions: Array<BackPressHandler> = [];
2021

21-
RCTDeviceEventEmitter.addListener(DEVICE_BACK_EVENT, function () {
22+
RCTDeviceEventEmitter.addListener(DEVICE_BACK_EVENT, function (nativeEvent) {
23+
const event = new HardwareBackPressEvent({timeStamp: nativeEvent?.timestamp});
2224
for (let i = _backPressSubscriptions.length - 1; i >= 0; i--) {
23-
if (_backPressSubscriptions[i]?.()) {
25+
if (_backPressSubscriptions[i]?.(event)) {
2426
return;
2527
}
2628
}

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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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+
// flowlint unsafe-getters-setters:off
12+
13+
import Event from '../../src/private/webapis/dom/events/Event';
14+
15+
export interface HardwareBackPressEventInit {
16+
+timeStamp?: number;
17+
}
18+
19+
/**
20+
* Event dispatched when the hardware back button is pressed on Android.
21+
*
22+
* The `timeStamp` property reflects the native timestamp from
23+
* `SystemClock.uptimeMillis()` captured when the back press was emitted,
24+
* or falls back to `performance.now()` at event construction time if
25+
* no native timestamp is available.
26+
*/
27+
export class HardwareBackPressEvent extends Event {
28+
_nativeTimestamp: ?number;
29+
30+
constructor(options?: ?HardwareBackPressEventInit) {
31+
super('hardwareBackPress');
32+
this._nativeTimestamp = options?.timeStamp;
33+
}
34+
35+
get timeStamp(): number {
36+
return this._nativeTimestamp ?? super.timeStamp;
37+
}
38+
}
39+
40+
export const HardwareBackPressEvent_public: typeof HardwareBackPressEvent =
41+
/* eslint-disable no-shadow */
42+
// $FlowExpectedError[incompatible-type]
43+
function HardwareBackPressEvent() {
44+
throw new TypeError(
45+
"Failed to construct 'HardwareBackPressEvent': Illegal constructor",
46+
);
47+
};
48+
49+
// $FlowExpectedError[prop-missing]
50+
HardwareBackPressEvent_public.prototype = HardwareBackPressEvent.prototype;

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

Lines changed: 11 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,13 @@ const BackHandler = {
2833
},
2934

3035
mockPressBack: function () {
36+
const event = new HardwareBackPressEventClass({
37+
timeStamp: performance.now(),
38+
});
3139
let invokeDefault = true;
3240
const subscriptions = [..._backPressSubscriptions].reverse();
3341
for (let i = 0; i < subscriptions.length; ++i) {
34-
if (subscriptions[i]()) {
42+
if (subscriptions[i](event)) {
3543
invokeDefault = false;
3644
break;
3745
}
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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
16+
describe('HardwareBackPressEvent', () => {
17+
it('extends Event', () => {
18+
const event = new HardwareBackPressEvent({timeStamp: 12345});
19+
20+
expect(event).toBeInstanceOf(Event);
21+
expect(event.type).toBe('hardwareBackPress');
22+
expect(event.bubbles).toBe(false);
23+
expect(event.cancelable).toBe(false);
24+
expect(event.composed).toBe(false);
25+
});
26+
27+
it('uses native timestamp as timeStamp when provided', () => {
28+
const event = new HardwareBackPressEvent({timeStamp: 12345});
29+
30+
expect(event.timeStamp).toBe(12345);
31+
});
32+
33+
it('falls back to performance.now() when no timestamp is provided', () => {
34+
const before = performance.now();
35+
const event = new HardwareBackPressEvent();
36+
const after = performance.now();
37+
38+
expect(event.timeStamp).toBeGreaterThanOrEqual(before);
39+
expect(event.timeStamp).toBeLessThanOrEqual(after);
40+
});
41+
42+
it('falls back to performance.now() when timestamp is undefined', () => {
43+
const before = performance.now();
44+
const event = new HardwareBackPressEvent(undefined);
45+
const after = performance.now();
46+
47+
expect(event.timeStamp).toBeGreaterThanOrEqual(before);
48+
expect(event.timeStamp).toBeLessThanOrEqual(after);
49+
});
50+
51+
it('does NOT allow changing the timeStamp value after construction', () => {
52+
const event = new HardwareBackPressEvent({timeStamp: 12345});
53+
54+
expect(() => {
55+
'use strict';
56+
// $FlowExpectedError[cannot-write]
57+
event.timeStamp = 999;
58+
}).toThrow();
59+
});
60+
61+
it('throws TypeError when constructed via public constructor', () => {
62+
const {
63+
HardwareBackPressEvent_public,
64+
} = require('react-native/Libraries/Utilities/HardwareBackPressEvent');
65+
66+
expect(() => {
67+
// $FlowExpectedError[incompatible-new]
68+
return new HardwareBackPressEvent_public();
69+
}).toThrow(
70+
"Failed to construct 'HardwareBackPressEvent': Illegal constructor",
71+
);
72+
});
73+
});

0 commit comments

Comments
 (0)