Avoid closure allocation in RCTDeviceEventEmitter.emit when tracing is disabled (#58661)

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#<type>)
  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
This commit is contained in:
secitr
2026-09-25 09:05:49 -07:00
committed by meta-codesync[bot]
parent b26baaeba0
commit 7f6043bdb0
2 changed files with 113 additions and 7 deletions
@@ -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<any>};
* NativeModules that emit events should instead subclass `NativeEventEmitter`.
*/
class RCTDeviceEventEmitterImpl extends EventEmitter<RCTDeviceEventDefinitions> {
// 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<TEvent extends keyof RCTDeviceEventDefinitions>(
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<RCTDeviceEventDefinitions> =
@@ -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);
});
});