diff --git a/packages/react-native/Libraries/Utilities/BackHandler.android.js b/packages/react-native/Libraries/Utilities/BackHandler.android.js index ff468f6500fc..d29e71fa07b7 100644 --- a/packages/react-native/Libraries/Utilities/BackHandler.android.js +++ b/packages/react-native/Libraries/Utilities/BackHandler.android.js @@ -9,18 +9,26 @@ */ import NativeDeviceEventManager from '../../Libraries/NativeModules/specs/NativeDeviceEventManager'; +import {setEventInitTimeStamp} from '../../src/private/webapis/dom/events/internals/EventInternals'; import RCTDeviceEventEmitter from '../EventEmitter/RCTDeviceEventEmitter'; +import {HardwareBackPressEvent} from './HardwareBackPressEvent'; const DEVICE_BACK_EVENT = 'hardwareBackPress'; type BackPressEventName = 'backPress' | 'hardwareBackPress'; -type BackPressHandler = () => ?boolean; +type BackPressHandler = (event: HardwareBackPressEvent) => ?boolean; const _backPressSubscriptions: Array = []; -RCTDeviceEventEmitter.addListener(DEVICE_BACK_EVENT, function () { +RCTDeviceEventEmitter.addListener(DEVICE_BACK_EVENT, function (nativeEvent) { + const options = {}; + const nativeTimestamp = nativeEvent?.timeStamp; + if (nativeTimestamp != null) { + setEventInitTimeStamp(options, nativeTimestamp); + } + const event = new HardwareBackPressEvent(options); for (let i = _backPressSubscriptions.length - 1; i >= 0; i--) { - if (_backPressSubscriptions[i]?.()) { + if (_backPressSubscriptions[i]?.(event)) { return; } } diff --git a/packages/react-native/Libraries/Utilities/BackHandler.d.ts b/packages/react-native/Libraries/Utilities/BackHandler.d.ts index 8ca8e1743d19..ac2a530a9c05 100644 --- a/packages/react-native/Libraries/Utilities/BackHandler.d.ts +++ b/packages/react-native/Libraries/Utilities/BackHandler.d.ts @@ -11,6 +11,16 @@ import {NativeEventSubscription} from '../EventEmitter/RCTNativeAppEventEmitter' export type BackPressEventName = 'hardwareBackPress'; +/** + * Event dispatched when the hardware back button is pressed. + * The `timeStamp` property reflects the native timestamp captured + * when the back press was emitted. + */ +export interface HardwareBackPressEvent { + readonly type: string; + readonly timeStamp: number; +} + /** * Detect hardware back button presses, and programmatically invoke the * default back button functionality to exit the app if there are no @@ -26,7 +36,7 @@ export interface BackHandlerStatic { exitApp(): void; addEventListener( eventName: BackPressEventName, - handler: () => boolean | null | undefined, + handler: (event: HardwareBackPressEvent) => boolean | null | undefined, ): NativeEventSubscription; } diff --git a/packages/react-native/Libraries/Utilities/BackHandler.ios.js b/packages/react-native/Libraries/Utilities/BackHandler.ios.js index 28d811f2f4b0..e9bb65f193bb 100644 --- a/packages/react-native/Libraries/Utilities/BackHandler.ios.js +++ b/packages/react-native/Libraries/Utilities/BackHandler.ios.js @@ -8,8 +8,10 @@ * @format */ +import type {HardwareBackPressEvent} from './HardwareBackPressEvent'; + type BackPressEventName = 'backPress' | 'hardwareBackPress'; -type BackPressHandler = () => ?boolean; +type BackPressHandler = (event: HardwareBackPressEvent) => ?boolean; function emptyFunction(): void {} diff --git a/packages/react-native/Libraries/Utilities/BackHandler.js.flow b/packages/react-native/Libraries/Utilities/BackHandler.js.flow index bf248e260729..eeef9af7b3fa 100644 --- a/packages/react-native/Libraries/Utilities/BackHandler.js.flow +++ b/packages/react-native/Libraries/Utilities/BackHandler.js.flow @@ -12,11 +12,16 @@ export type BackPressEventName = 'backPress' | 'hardwareBackPress'; +export interface HardwareBackPressEvent { + +type: string; + +timeStamp: number; +} + type TBackHandler = { exitApp(): void, addEventListener( eventName: BackPressEventName, - handler: () => ?boolean, + handler: (event: HardwareBackPressEvent) => ?boolean, ): {remove: () => void, ...}, }; diff --git a/packages/react-native/Libraries/Utilities/HardwareBackPressEvent.js b/packages/react-native/Libraries/Utilities/HardwareBackPressEvent.js new file mode 100644 index 000000000000..8576c6060b90 --- /dev/null +++ b/packages/react-native/Libraries/Utilities/HardwareBackPressEvent.js @@ -0,0 +1,22 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import type {EventInit} from '../../src/private/webapis/dom/events/Event'; + +import Event from '../../src/private/webapis/dom/events/Event'; + +/** + * Event dispatched when the hardware back button is pressed on Android. + */ +export class HardwareBackPressEvent extends Event { + constructor(options?: ?EventInit) { + super('hardwareBackPress', options); + } +} diff --git a/packages/react-native/Libraries/Utilities/__mocks__/BackHandler.js b/packages/react-native/Libraries/Utilities/__mocks__/BackHandler.js index 6563b7869217..9e964a069ba3 100644 --- a/packages/react-native/Libraries/Utilities/__mocks__/BackHandler.js +++ b/packages/react-native/Libraries/Utilities/__mocks__/BackHandler.js @@ -9,15 +9,20 @@ */ import type {BackPressEventName} from '../BackHandler'; +import type {HardwareBackPressEvent} from '../HardwareBackPressEvent'; -const _backPressSubscriptions = new Set<() => ?boolean>(); +import {HardwareBackPressEvent as HardwareBackPressEventClass} from '../HardwareBackPressEvent'; + +const _backPressSubscriptions = new Set< + (event: HardwareBackPressEvent) => ?boolean, +>(); const BackHandler = { exitApp: jest.fn() as () => void, addEventListener: function ( eventName: BackPressEventName, - handler: () => ?boolean, + handler: (event: HardwareBackPressEvent) => ?boolean, ): {remove: () => void, ...} { _backPressSubscriptions.add(handler); return { @@ -28,10 +33,11 @@ const BackHandler = { }, mockPressBack: function () { + const event = new HardwareBackPressEventClass(); let invokeDefault = true; const subscriptions = [..._backPressSubscriptions].reverse(); for (let i = 0; i < subscriptions.length; ++i) { - if (subscriptions[i]()) { + if (subscriptions[i](event)) { invokeDefault = false; break; } diff --git a/packages/react-native/Libraries/Utilities/__tests__/BackHandler-itest.js b/packages/react-native/Libraries/Utilities/__tests__/BackHandler-itest.js new file mode 100644 index 000000000000..a6c3b747bb7e --- /dev/null +++ b/packages/react-native/Libraries/Utilities/__tests__/BackHandler-itest.js @@ -0,0 +1,144 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HardwareBackPressEvent} from 'react-native/Libraries/Utilities/BackHandler'; + +import RCTDeviceEventEmitter from 'react-native/Libraries/EventEmitter/RCTDeviceEventEmitter'; +import BackHandler from 'react-native/Libraries/Utilities/BackHandler'; +import {HardwareBackPressEvent as HardwareBackPressEventClass} from 'react-native/Libraries/Utilities/HardwareBackPressEvent'; + +describe('BackHandler', () => { + const subscriptions: Array<{remove: () => void, ...}> = []; + + afterEach(() => { + for (const sub of subscriptions) { + sub.remove(); + } + subscriptions.length = 0; + }); + + it('calls handlers in reverse order (LIFO)', () => { + const callOrder: Array = []; + const handler1 = (_event: HardwareBackPressEvent) => { + callOrder.push('first'); + return false; + }; + const handler2 = (_event: HardwareBackPressEvent) => { + callOrder.push('second'); + return true; + }; + + subscriptions.push( + BackHandler.addEventListener('hardwareBackPress', handler1), + ); + subscriptions.push( + BackHandler.addEventListener('hardwareBackPress', handler2), + ); + + RCTDeviceEventEmitter.emit('hardwareBackPress', {timeStamp: 100}); + + expect(callOrder).toEqual(['second']); + }); + + it('calls all handlers when none return true', () => { + const callOrder: Array = []; + const handler1 = (_event: HardwareBackPressEvent) => { + callOrder.push('first'); + return false; + }; + const handler2 = (_event: HardwareBackPressEvent) => { + callOrder.push('second'); + return false; + }; + + subscriptions.push( + BackHandler.addEventListener('hardwareBackPress', handler1), + ); + subscriptions.push( + BackHandler.addEventListener('hardwareBackPress', handler2), + ); + + RCTDeviceEventEmitter.emit('hardwareBackPress', {timeStamp: 100}); + + expect(callOrder).toEqual(['second', 'first']); + }); + + it('passes HardwareBackPressEvent to handlers', () => { + let receivedEvent: ?HardwareBackPressEvent = null; + const handler = (event: HardwareBackPressEvent) => { + receivedEvent = event; + return true; + }; + + subscriptions.push( + BackHandler.addEventListener('hardwareBackPress', handler), + ); + + RCTDeviceEventEmitter.emit('hardwareBackPress', {timeStamp: 42}); + + expect(receivedEvent).toBeInstanceOf(HardwareBackPressEventClass); + }); + + it('event has native timestamp as timeStamp', () => { + let receivedEvent: ?HardwareBackPressEvent = null; + const handler = (event: HardwareBackPressEvent) => { + receivedEvent = event; + return true; + }; + + subscriptions.push( + BackHandler.addEventListener('hardwareBackPress', handler), + ); + + RCTDeviceEventEmitter.emit('hardwareBackPress', {timeStamp: 42}); + + expect(receivedEvent?.timeStamp).toBe(42); + }); + + it('event falls back to performance.now() when no native timestamp', () => { + let receivedEvent: ?HardwareBackPressEvent = null; + const handler = (event: HardwareBackPressEvent) => { + receivedEvent = event; + return true; + }; + + subscriptions.push( + BackHandler.addEventListener('hardwareBackPress', handler), + ); + + const before = performance.now(); + RCTDeviceEventEmitter.emit('hardwareBackPress', null); + const after = performance.now(); + + const timeStamp = receivedEvent?.timeStamp; + expect(timeStamp).not.toBeNull(); + if (timeStamp != null) { + expect(timeStamp).toBeGreaterThanOrEqual(before); + expect(timeStamp).toBeLessThanOrEqual(after); + } + }); + + it('removes handler on subscription.remove()', () => { + let called = false; + const handler = (_event: HardwareBackPressEvent) => { + called = true; + return true; + }; + + const sub = BackHandler.addEventListener('hardwareBackPress', handler); + sub.remove(); + + RCTDeviceEventEmitter.emit('hardwareBackPress', {timeStamp: 100}); + + expect(called).toBe(false); + }); +}); diff --git a/packages/react-native/Libraries/Utilities/__tests__/HardwareBackPressEvent-itest.js b/packages/react-native/Libraries/Utilities/__tests__/HardwareBackPressEvent-itest.js new file mode 100644 index 000000000000..519da4080d49 --- /dev/null +++ b/packages/react-native/Libraries/Utilities/__tests__/HardwareBackPressEvent-itest.js @@ -0,0 +1,65 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import {HardwareBackPressEvent} from 'react-native/Libraries/Utilities/HardwareBackPressEvent'; +import Event from 'react-native/src/private/webapis/dom/events/Event'; +import {setEventInitTimeStamp} from 'react-native/src/private/webapis/dom/events/internals/EventInternals'; + +describe('HardwareBackPressEvent', () => { + it('extends Event', () => { + const event = new HardwareBackPressEvent(); + + expect(event).toBeInstanceOf(Event); + expect(event.type).toBe('hardwareBackPress'); + expect(event.bubbles).toBe(false); + expect(event.cancelable).toBe(false); + expect(event.composed).toBe(false); + }); + + it('uses native timestamp as timeStamp when provided via setEventInitTimeStamp', () => { + const options = {}; + setEventInitTimeStamp(options, 12345); + const event = new HardwareBackPressEvent(options); + + expect(event.timeStamp).toBe(12345); + }); + + it('falls back to performance.now() when no timestamp is provided', () => { + const before = performance.now(); + const event = new HardwareBackPressEvent(); + const after = performance.now(); + + expect(event.timeStamp).toBeGreaterThanOrEqual(before); + expect(event.timeStamp).toBeLessThanOrEqual(after); + }); + + it('falls back to performance.now() when options is undefined', () => { + const before = performance.now(); + const event = new HardwareBackPressEvent(undefined); + const after = performance.now(); + + expect(event.timeStamp).toBeGreaterThanOrEqual(before); + expect(event.timeStamp).toBeLessThanOrEqual(after); + }); + + it('does NOT allow changing the timeStamp value after construction', () => { + const options = {}; + setEventInitTimeStamp(options, 12345); + const event = new HardwareBackPressEvent(options); + + expect(() => { + 'use strict'; + // $FlowExpectedError[cannot-write] + event.timeStamp = 999; + }).toThrow(); + }); +}); 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 a139ffe3424a..9ee720cf8bbc 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 @@ -36,7 +36,8 @@ public open class DeviceEventManagerModule( public open fun emitHardwareBackPressed() { val reactApplicationContext: ReactApplicationContext? = getReactApplicationContextIfActiveOrWarn() - reactApplicationContext?.emitDeviceEvent("hardwareBackPress", null) + val map = buildReadableMap { put("timeStamp", System.nanoTime() / 1_000_000.0) } + reactApplicationContext?.emitDeviceEvent("hardwareBackPress", map) } /** Sends an event to the JS instance that a new intent was received. */ diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 12e2020d70a7..407700bfb780 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<<481366a332c1435df38ef98031869bb9>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -2479,6 +2479,10 @@ declare function getWithFallback_DEPRECATED( ): React.ComponentType declare type hairlineWidth = typeof hairlineWidth declare type Handle = number +declare interface HardwareBackPressEvent { + readonly timeStamp: number + readonly type: string +} declare type Headers = { [name: string]: string } @@ -5076,7 +5080,7 @@ declare type TaskProvider = () => HeadlessTask declare type TBackHandler = { addEventListener( eventName: BackPressEventName, - handler: () => boolean | undefined, + handler: (event: HardwareBackPressEvent) => boolean | undefined, ): { remove: () => void } @@ -6015,7 +6019,7 @@ export { AppStateStatus, // 447e5ef2 Appearance, // 00cbaa0a AutoCapitalize, // c0e857a0 - BackHandler, // 0b13c289 + BackHandler, // f139fc69 BackPressEventName, // 4620fb76 BlurEvent, // 870b9bb5 BoxShadowValue, // b679703f diff --git a/packages/react-native/src/private/webapis/dom/events/Event.js b/packages/react-native/src/private/webapis/dom/events/Event.js index f97ead512ab9..f918f9796e56 100644 --- a/packages/react-native/src/private/webapis/dom/events/Event.js +++ b/packages/react-native/src/private/webapis/dom/events/Event.js @@ -21,6 +21,7 @@ import {setPlatformObject} from '../../webidl/PlatformObjects'; import { COMPOSED_PATH_KEY, CURRENT_TARGET_KEY, + EVENT_INIT_TIMESTAMP_KEY, EVENT_PHASE_KEY, IN_PASSIVE_LISTENER_FLAG_KEY, IS_TRUSTED_KEY, @@ -60,7 +61,7 @@ export default class Event { _type: string; _defaultPrevented: boolean = false; - _timeStamp: number = performance.now(); + _timeStamp: number; // $FlowExpectedError[unsupported-syntax] [COMPOSED_PATH_KEY]: boolean = []; @@ -109,6 +110,14 @@ export default class Event { this._bubbles = Boolean(options?.bubbles); this._cancelable = Boolean(options?.cancelable); this._composed = Boolean(options?.composed); + + // For internal construction of events using a custom timestamp (instead of + // event object creation), for use cases like dispatching events from the + // host platform using the original timestamps. + // $FlowExpectedError[prop-missing] + const initTimestamp: number | void = options?.[EVENT_INIT_TIMESTAMP_KEY]; + this._timeStamp = + initTimestamp !== undefined ? initTimestamp : performance.now(); } get bubbles(): boolean { diff --git a/packages/react-native/src/private/webapis/dom/events/__tests__/Event-itest.js b/packages/react-native/src/private/webapis/dom/events/__tests__/Event-itest.js index 21609b28a039..4baa3d2eec41 100644 --- a/packages/react-native/src/private/webapis/dom/events/__tests__/Event-itest.js +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/Event-itest.js @@ -11,7 +11,10 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import Event from 'react-native/src/private/webapis/dom/events/Event'; -import {setInPassiveListenerFlag} from 'react-native/src/private/webapis/dom/events/internals/EventInternals'; +import { + setEventInitTimeStamp, + setInPassiveListenerFlag, +} from 'react-native/src/private/webapis/dom/events/internals/EventInternals'; describe('Event', () => { it('provides read-only constants for event phases', () => { @@ -233,6 +236,23 @@ describe('Event', () => { expect(event.timeStamp).toBeLessThanOrEqual(upperBoundTimestamp); }); + it('should use a custom timestamp when set via setEventInitTimeStamp', () => { + const customTimestamp = 12345.678; + const options = {}; + setEventInitTimeStamp(options, customTimestamp); + const event = new Event('custom', options); + + expect(event.timeStamp).toBe(customTimestamp); + }); + + it('should accept zero as a valid custom timestamp', () => { + const options = {}; + setEventInitTimeStamp(options, 0); + const event = new Event('custom', options); + + expect(event.timeStamp).toBe(0); + }); + describe('preventDefault', () => { let originalConsoleError; let consoleErrorMock; diff --git a/packages/react-native/src/private/webapis/dom/events/internals/EventInternals.js b/packages/react-native/src/private/webapis/dom/events/internals/EventInternals.js index f16806795857..32f36e2013ee 100644 --- a/packages/react-native/src/private/webapis/dom/events/internals/EventInternals.js +++ b/packages/react-native/src/private/webapis/dom/events/internals/EventInternals.js @@ -14,7 +14,7 @@ * (only with public exports). */ -import type Event, {EventPhase} from '../Event'; +import type Event, {EventInit, EventPhase} from '../Event'; import type EventTarget from '../EventTarget'; export const COMPOSED_PATH_KEY: symbol = Symbol('composedPath'); @@ -30,6 +30,11 @@ export const STOP_IMMEDIATE_PROPAGATION_FLAG_KEY: symbol = Symbol( export const STOP_PROPAGATION_FLAG_KEY: symbol = Symbol('stopPropagationFlag'); export const TARGET_KEY: symbol = Symbol('target'); +// For internal construction of events using a custom timestamp (instead of +// event object creation), for use cases like dispatching events from the host +// platform using the original timestamps. +export const EVENT_INIT_TIMESTAMP_KEY: symbol = Symbol('eventInitTimestamp'); + export function getCurrentTarget(event: Event): EventTarget | null { // $FlowExpectedError[prop-missing] return event[CURRENT_TARGET_KEY]; @@ -118,3 +123,15 @@ export function setTarget(event: Event, target: EventTarget | null): void { // $FlowExpectedError[prop-missing] event[TARGET_KEY] = target; } + +export function setEventInitTimeStamp( + eventInit: EventInit, + timeStamp: number, +): void { + if (typeof timeStamp !== 'number') { + return; + } + // $FlowExpectedError[prop-missing] + // $FlowExpectedError[invalid-computed-prop] + eventInit[EVENT_INIT_TIMESTAMP_KEY] = timeStamp; +}