From 7f6043bdb045386a52cc465cadaf7a84fee0bf2b Mon Sep 17 00:00:00 2001 From: secitr Date: Fri, 25 Sep 2026 09:05:49 -0700 Subject: [PATCH] Avoid closure allocation in RCTDeviceEventEmitter.emit when tracing is disabled (#58661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: RCTDeviceEventEmitter.emit is one of the hottest paths in React Native: NativeEventEmitter.emit delegates to it, so every event delivered from native (scroll, touch, keyboard, app state, ...) goes through this method. The current implementation wraps every call in Systrace.trace, which always allocates two closures (a lazy event-name thunk and a callback) on every emit — even when tracing is disabled, which is the common case. This changes emit to guard on Systrace.isEnabled() and use beginEvent/endEvent directly: - tracing disabled (common case): one branch check + a plain call — zero allocations - tracing enabled: same trace section name (RCTDeviceEventEmitter.emit#) and the same begin/end semantics, including endEvent() in a finally block when a listener throws Behavior is unchanged; only the fast-path allocations are removed. ## Changelog: [General] [Changed] - RCTDeviceEventEmitter.emit no longer allocates closures on every emit when tracing is disabled, reducing per-event allocation on the native-to-JS event path Pull Request resolved: https://github.com/react/react-native/pull/58661 Test Plan: - New Fantom integration tests in packages/react-native/Libraries/EventEmitter/__tests__/RCTDeviceEventEmitter-itest.js (public API, runs against Hermes): - event + args are forwarded to listeners - no trace section calls when tracing is disabled - trace section is begun/ended with the correct name when tracing is enabled - end section is still emitted when a listener throws - __RCTProfileIsProfiling fallback still enables tracing - yarn test packages/react-native/Libraries — 433 tests pass (30 suites) - yarn flow-check — 0 errors - yarn lint — 0 warnings - Micro-benchmark (Node v24, tracing disabled, 5M emits with a scroll-like payload): - before: 158ms total (~31.6 ns/emit) - after: 117ms total (~23.4 ns/emit) - ~26% faster per emit; on Hermes/mobile CPUs the win comes from avoiding two heap-allocated closures per event Reviewed By: andrewdacenko Differential Revision: D121799063 Pulled By: cortinico fbshipit-source-id: 86607670dfe67fa2faf1df130b8b86d828cc2564 --- .../EventEmitter/RCTDeviceEventEmitter.js | 21 ++-- .../__tests__/RCTDeviceEventEmitter-itest.js | 99 +++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 packages/react-native/Libraries/EventEmitter/__tests__/RCTDeviceEventEmitter-itest.js diff --git a/packages/react-native/Libraries/EventEmitter/RCTDeviceEventEmitter.js b/packages/react-native/Libraries/EventEmitter/RCTDeviceEventEmitter.js index 9b371bdb5b3..74aa36574f8 100644 --- a/packages/react-native/Libraries/EventEmitter/RCTDeviceEventEmitter.js +++ b/packages/react-native/Libraries/EventEmitter/RCTDeviceEventEmitter.js @@ -10,7 +10,7 @@ import type {IEventEmitter} from '../vendor/emitter/EventEmitter'; -import {trace} from '../Performance/Systrace'; +import {beginEvent, endEvent, isEnabled} from '../Performance/Systrace'; import EventEmitter from '../vendor/emitter/EventEmitter'; // FIXME: use typed events @@ -24,17 +24,24 @@ type RCTDeviceEventDefinitions = {[name: string]: Array}; * NativeModules that emit events should instead subclass `NativeEventEmitter`. */ class RCTDeviceEventEmitterImpl extends EventEmitter { - // Add systrace to RCTDeviceEventEmitter.emit method for debugging + // Add systrace to RCTDeviceEventEmitter.emit method for debugging. + // `beginEvent`/`endEvent` are used instead of `trace` so that no closures + // are allocated when tracing is disabled (the common case); `trace` always + // allocates a callback, which is costly on this hot path. emit( eventType: TEvent, ...args: RCTDeviceEventDefinitions[TEvent] ): void { - trace( - () => `RCTDeviceEventEmitter.emit#${eventType}`, - () => { + if (isEnabled()) { + beginEvent(`RCTDeviceEventEmitter.emit#${eventType}`); + try { super.emit(eventType, ...args); - }, - ); + } finally { + endEvent(); + } + } else { + super.emit(eventType, ...args); + } } } const RCTDeviceEventEmitter: IEventEmitter = diff --git a/packages/react-native/Libraries/EventEmitter/__tests__/RCTDeviceEventEmitter-itest.js b/packages/react-native/Libraries/EventEmitter/__tests__/RCTDeviceEventEmitter-itest.js new file mode 100644 index 00000000000..f5ed16186e9 --- /dev/null +++ b/packages/react-native/Libraries/EventEmitter/__tests__/RCTDeviceEventEmitter-itest.js @@ -0,0 +1,99 @@ +/** + * 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 {DeviceEventEmitter} from 'react-native'; + +const TRACE_TAG_REACT = 1 << 13; // eslint-disable-line no-bitwise + +function enableTracing() { + global.nativeTraceIsTracing = jest.fn(() => true); + global.nativeTraceBeginSection = jest.fn(); + global.nativeTraceEndSection = jest.fn(); +} + +function disableTracing() { + delete global.nativeTraceIsTracing; + delete global.nativeTraceBeginSection; + delete global.nativeTraceEndSection; + delete global.__RCTProfileIsProfiling; +} + +describe('DeviceEventEmitter', () => { + afterEach(() => { + DeviceEventEmitter.removeAllListeners(); + disableTracing(); + }); + + it('forwards events and arguments to listeners', () => { + const listener = jest.fn(); + DeviceEventEmitter.addListener('event', listener); + + DeviceEventEmitter.emit('event', 'one', 2); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('one', 2); + }); + + it('does not call trace sections when tracing is disabled', () => { + const listener = jest.fn(); + DeviceEventEmitter.addListener('event', listener); + + DeviceEventEmitter.emit('event'); + + expect(listener).toHaveBeenCalledTimes(1); + expect(global.nativeTraceBeginSection).toBeUndefined(); + }); + + it('wraps emit in a trace section when tracing is enabled', () => { + enableTracing(); + const listener = jest.fn(); + DeviceEventEmitter.addListener('event', listener); + + DeviceEventEmitter.emit('event'); + + expect(global.nativeTraceBeginSection).toHaveBeenCalledTimes(1); + expect(global.nativeTraceBeginSection).toHaveBeenCalledWith( + TRACE_TAG_REACT, + 'RCTDeviceEventEmitter.emit#event', + undefined, + ); + expect(listener).toHaveBeenCalledTimes(1); + expect(global.nativeTraceEndSection).toHaveBeenCalledTimes(1); + expect(global.nativeTraceEndSection).toHaveBeenCalledWith( + TRACE_TAG_REACT, + undefined, + ); + }); + + it('ends the trace section even when a listener throws', () => { + enableTracing(); + DeviceEventEmitter.addListener('event', () => { + throw new Error('boom'); + }); + + expect(() => DeviceEventEmitter.emit('event')).toThrow('boom'); + + expect(global.nativeTraceBeginSection).toHaveBeenCalledTimes(1); + expect(global.nativeTraceEndSection).toHaveBeenCalledTimes(1); + }); + + it('traces when __RCTProfileIsProfiling is set and nativeTraceIsTracing is absent', () => { + global.__RCTProfileIsProfiling = true; + global.nativeTraceBeginSection = jest.fn(); + global.nativeTraceEndSection = jest.fn(); + + DeviceEventEmitter.emit('event'); + + expect(global.nativeTraceBeginSection).toHaveBeenCalledTimes(1); + expect(global.nativeTraceEndSection).toHaveBeenCalledTimes(1); + }); +});