Skip to content

Commit 7f6043b

Browse files
secitrmeta-codesync[bot]
authored andcommitted
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: #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
1 parent b26baae commit 7f6043b

2 files changed

Lines changed: 113 additions & 7 deletions

File tree

‎packages/react-native/Libraries/EventEmitter/RCTDeviceEventEmitter.js‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import type {IEventEmitter} from '../vendor/emitter/EventEmitter';
1212

13-
import {trace} from '../Performance/Systrace';
13+
import {beginEvent, endEvent, isEnabled} from '../Performance/Systrace';
1414
import EventEmitter from '../vendor/emitter/EventEmitter';
1515

1616
// FIXME: use typed events
@@ -24,17 +24,24 @@ type RCTDeviceEventDefinitions = {[name: string]: Array<any>};
2424
* NativeModules that emit events should instead subclass `NativeEventEmitter`.
2525
*/
2626
class RCTDeviceEventEmitterImpl extends EventEmitter<RCTDeviceEventDefinitions> {
27-
// Add systrace to RCTDeviceEventEmitter.emit method for debugging
27+
// Add systrace to RCTDeviceEventEmitter.emit method for debugging.
28+
// `beginEvent`/`endEvent` are used instead of `trace` so that no closures
29+
// are allocated when tracing is disabled (the common case); `trace` always
30+
// allocates a callback, which is costly on this hot path.
2831
emit<TEvent extends keyof RCTDeviceEventDefinitions>(
2932
eventType: TEvent,
3033
...args: RCTDeviceEventDefinitions[TEvent]
3134
): void {
32-
trace(
33-
() => `RCTDeviceEventEmitter.emit#${eventType}`,
34-
() => {
35+
if (isEnabled()) {
36+
beginEvent(`RCTDeviceEventEmitter.emit#${eventType}`);
37+
try {
3538
super.emit(eventType, ...args);
36-
},
37-
);
39+
} finally {
40+
endEvent();
41+
}
42+
} else {
43+
super.emit(eventType, ...args);
44+
}
3845
}
3946
}
4047
const RCTDeviceEventEmitter: IEventEmitter<RCTDeviceEventDefinitions> =
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 {DeviceEventEmitter} from 'react-native';
14+
15+
const TRACE_TAG_REACT = 1 << 13; // eslint-disable-line no-bitwise
16+
17+
function enableTracing() {
18+
global.nativeTraceIsTracing = jest.fn(() => true);
19+
global.nativeTraceBeginSection = jest.fn();
20+
global.nativeTraceEndSection = jest.fn();
21+
}
22+
23+
function disableTracing() {
24+
delete global.nativeTraceIsTracing;
25+
delete global.nativeTraceBeginSection;
26+
delete global.nativeTraceEndSection;
27+
delete global.__RCTProfileIsProfiling;
28+
}
29+
30+
describe('DeviceEventEmitter', () => {
31+
afterEach(() => {
32+
DeviceEventEmitter.removeAllListeners();
33+
disableTracing();
34+
});
35+
36+
it('forwards events and arguments to listeners', () => {
37+
const listener = jest.fn();
38+
DeviceEventEmitter.addListener('event', listener);
39+
40+
DeviceEventEmitter.emit('event', 'one', 2);
41+
42+
expect(listener).toHaveBeenCalledTimes(1);
43+
expect(listener).toHaveBeenCalledWith('one', 2);
44+
});
45+
46+
it('does not call trace sections when tracing is disabled', () => {
47+
const listener = jest.fn();
48+
DeviceEventEmitter.addListener('event', listener);
49+
50+
DeviceEventEmitter.emit('event');
51+
52+
expect(listener).toHaveBeenCalledTimes(1);
53+
expect(global.nativeTraceBeginSection).toBeUndefined();
54+
});
55+
56+
it('wraps emit in a trace section when tracing is enabled', () => {
57+
enableTracing();
58+
const listener = jest.fn();
59+
DeviceEventEmitter.addListener('event', listener);
60+
61+
DeviceEventEmitter.emit('event');
62+
63+
expect(global.nativeTraceBeginSection).toHaveBeenCalledTimes(1);
64+
expect(global.nativeTraceBeginSection).toHaveBeenCalledWith(
65+
TRACE_TAG_REACT,
66+
'RCTDeviceEventEmitter.emit#event',
67+
undefined,
68+
);
69+
expect(listener).toHaveBeenCalledTimes(1);
70+
expect(global.nativeTraceEndSection).toHaveBeenCalledTimes(1);
71+
expect(global.nativeTraceEndSection).toHaveBeenCalledWith(
72+
TRACE_TAG_REACT,
73+
undefined,
74+
);
75+
});
76+
77+
it('ends the trace section even when a listener throws', () => {
78+
enableTracing();
79+
DeviceEventEmitter.addListener('event', () => {
80+
throw new Error('boom');
81+
});
82+
83+
expect(() => DeviceEventEmitter.emit('event')).toThrow('boom');
84+
85+
expect(global.nativeTraceBeginSection).toHaveBeenCalledTimes(1);
86+
expect(global.nativeTraceEndSection).toHaveBeenCalledTimes(1);
87+
});
88+
89+
it('traces when __RCTProfileIsProfiling is set and nativeTraceIsTracing is absent', () => {
90+
global.__RCTProfileIsProfiling = true;
91+
global.nativeTraceBeginSection = jest.fn();
92+
global.nativeTraceEndSection = jest.fn();
93+
94+
DeviceEventEmitter.emit('event');
95+
96+
expect(global.nativeTraceBeginSection).toHaveBeenCalledTimes(1);
97+
expect(global.nativeTraceEndSection).toHaveBeenCalledTimes(1);
98+
});
99+
});

0 commit comments

Comments
 (0)