Skip to content

Commit bd57819

Browse files
author
Brian Vaughn
authored
Inlined DevTools event emitter impl (facebook#18378)
DevTools previously used the NPM events package for dispatching events. This package has an unfortunate flaw though- if a listener throws during event dispatch, no subsequent listeners are called. I've replaced that event dispatcher with my own implementation that ensures all listeners are called before it re-throws an error. This commit replaces that event emitter with a custom implementation that calls all listeners before re-throwing an error.
1 parent 6498f62 commit bd57819

File tree

8 files changed

+206
-21
lines changed

8 files changed

+206
-21
lines changed

packages/react-devtools-shared/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
"@reach/menu-button": "^0.1.17",
1212
"@reach/tooltip": "^0.2.2",
1313
"clipboard-js": "^0.3.6",
14-
"events": "^3.0.0",
1514
"local-storage-fallback": "^4.1.1",
1615
"lodash.throttle": "^4.1.1",
1716
"memoize-one": "^3.1.1",
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its 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
8+
*/
9+
10+
describe('events', () => {
11+
let dispatcher;
12+
13+
beforeEach(() => {
14+
const EventEmitter = require('../events').default;
15+
16+
dispatcher = new EventEmitter();
17+
});
18+
19+
it('can dispatch an event with no listeners', () => {
20+
dispatcher.emit('event', 123);
21+
});
22+
23+
it('handles a listener being attached multiple times', () => {
24+
const callback = jest.fn();
25+
26+
dispatcher.addListener('event', callback);
27+
dispatcher.addListener('event', callback);
28+
29+
dispatcher.emit('event', 123);
30+
expect(callback).toHaveBeenCalledTimes(1);
31+
expect(callback).toHaveBeenCalledWith(123);
32+
});
33+
34+
it('notifies all attached listeners of events', () => {
35+
const callback1 = jest.fn();
36+
const callback2 = jest.fn();
37+
const callback3 = jest.fn();
38+
39+
dispatcher.addListener('event', callback1);
40+
dispatcher.addListener('event', callback2);
41+
dispatcher.addListener('other-event', callback3);
42+
dispatcher.emit('event', 123);
43+
44+
expect(callback1).toHaveBeenCalledTimes(1);
45+
expect(callback1).toHaveBeenCalledWith(123);
46+
expect(callback2).toHaveBeenCalledTimes(1);
47+
expect(callback2).toHaveBeenCalledWith(123);
48+
expect(callback3).not.toHaveBeenCalled();
49+
});
50+
51+
it('calls later listeners before re-throwing if an earlier one throws', () => {
52+
const callbackThatThrows = jest.fn(() => {
53+
throw Error('expected');
54+
});
55+
const callback = jest.fn();
56+
57+
dispatcher.addListener('event', callbackThatThrows);
58+
dispatcher.addListener('event', callback);
59+
60+
expect(() => {
61+
dispatcher.emit('event', 123);
62+
}).toThrow('expected');
63+
64+
expect(callbackThatThrows).toHaveBeenCalledTimes(1);
65+
expect(callbackThatThrows).toHaveBeenCalledWith(123);
66+
expect(callback).toHaveBeenCalledTimes(1);
67+
expect(callback).toHaveBeenCalledWith(123);
68+
});
69+
70+
it('removes attached listeners', () => {
71+
const callback1 = jest.fn();
72+
const callback2 = jest.fn();
73+
74+
dispatcher.addListener('event', callback1);
75+
dispatcher.addListener('other-event', callback2);
76+
dispatcher.removeListener('event', callback1);
77+
dispatcher.emit('event', 123);
78+
expect(callback1).not.toHaveBeenCalled();
79+
dispatcher.emit('other-event', 123);
80+
expect(callback2).toHaveBeenCalledTimes(1);
81+
expect(callback2).toHaveBeenCalledWith(123);
82+
});
83+
84+
it('removes all listeners', () => {
85+
const callback1 = jest.fn();
86+
const callback2 = jest.fn();
87+
const callback3 = jest.fn();
88+
89+
dispatcher.addListener('event', callback1);
90+
dispatcher.addListener('event', callback2);
91+
dispatcher.addListener('other-event', callback3);
92+
dispatcher.removeAllListeners();
93+
dispatcher.emit('event', 123);
94+
dispatcher.emit('other-event', 123);
95+
96+
expect(callback1).not.toHaveBeenCalled();
97+
expect(callback2).not.toHaveBeenCalled();
98+
expect(callback3).not.toHaveBeenCalled();
99+
});
100+
101+
it('should call the initial listeners even if others are added or removed during a dispatch', () => {
102+
const callback1 = jest.fn(() => {
103+
dispatcher.removeListener('event', callback2);
104+
dispatcher.addListener('event', callback3);
105+
});
106+
const callback2 = jest.fn();
107+
const callback3 = jest.fn();
108+
109+
dispatcher.addListener('event', callback1);
110+
dispatcher.addListener('event', callback2);
111+
112+
dispatcher.emit('event', 123);
113+
expect(callback1).toHaveBeenCalledTimes(1);
114+
expect(callback1).toHaveBeenCalledWith(123);
115+
expect(callback2).toHaveBeenCalledTimes(1);
116+
expect(callback2).toHaveBeenCalledWith(123);
117+
expect(callback3).not.toHaveBeenCalled();
118+
119+
dispatcher.emit('event', 456);
120+
expect(callback1).toHaveBeenCalledTimes(2);
121+
expect(callback1).toHaveBeenCalledWith(456);
122+
expect(callback2).toHaveBeenCalledTimes(1);
123+
expect(callback3).toHaveBeenCalledTimes(1);
124+
expect(callback3).toHaveBeenCalledWith(456);
125+
});
126+
});

packages/react-devtools-shared/src/backend/agent.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @flow
88
*/
99

10-
import EventEmitter from 'events';
10+
import EventEmitter from '../events';
1111
import throttle from 'lodash.throttle';
1212
import {
1313
SESSION_STORAGE_LAST_SELECTION_KEY,

packages/react-devtools-shared/src/bridge.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @flow
88
*/
99

10-
import EventEmitter from 'events';
10+
import EventEmitter from './events';
1111

1212
import type {ComponentFilter, Wall} from './types';
1313
import type {

packages/react-devtools-shared/src/devtools/ProfilerStore.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @flow
88
*/
99

10-
import EventEmitter from 'events';
10+
import EventEmitter from '../events';
1111
import {prepareProfilingDataFrontendFromBackendAndStore} from './views/Profiler/utils';
1212
import ProfilingCache from './ProfilingCache';
1313
import Store from './store';

packages/react-devtools-shared/src/devtools/store.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @flow
88
*/
99

10-
import EventEmitter from 'events';
10+
import EventEmitter from '../events';
1111
import {inspect} from 'util';
1212
import {
1313
TREE_OPERATION_ADD,
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its 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
8+
*/
9+
10+
export default class EventEmitter<Events: Object> {
11+
listenersMap: Map<string, Array<Function>> = new Map();
12+
13+
addListener<Event: $Keys<Events>>(
14+
event: Event,
15+
listener: (...$ElementType<Events, Event>) => any,
16+
): void {
17+
let listeners = this.listenersMap.get(event);
18+
if (listeners === undefined) {
19+
this.listenersMap.set(event, [listener]);
20+
} else {
21+
const index = listeners.indexOf(listener);
22+
if (index < 0) {
23+
listeners.push(listener);
24+
}
25+
}
26+
}
27+
28+
emit<Event: $Keys<Events>>(
29+
event: Event,
30+
...args: $ElementType<Events, Event>
31+
): void {
32+
const listeners = this.listenersMap.get(event);
33+
if (listeners !== undefined) {
34+
if (listeners.length === 1) {
35+
// No need to clone or try/catch
36+
const listener = listeners[0];
37+
listener.apply(null, args);
38+
} else {
39+
let didThrow = false;
40+
let caughtError = null;
41+
42+
const clonedListeners = Array.from(listeners);
43+
for (let i = 0; i < clonedListeners.length; i++) {
44+
const listener = clonedListeners[i];
45+
try {
46+
listener.apply(null, args);
47+
} catch (error) {
48+
if (caughtError === null) {
49+
didThrow = true;
50+
caughtError = error;
51+
}
52+
}
53+
}
54+
55+
if (didThrow) {
56+
throw caughtError;
57+
}
58+
}
59+
}
60+
}
61+
62+
removeAllListeners(): void {
63+
this.listenersMap.clear();
64+
}
65+
66+
removeListener(event: $Keys<Events>, listener: Function): void {
67+
const listeners = this.listenersMap.get(event);
68+
if (listeners !== undefined) {
69+
const index = listeners.indexOf(listener);
70+
if (index >= 0) {
71+
listeners.splice(index, 1);
72+
}
73+
}
74+
}
75+
}

scripts/flow/react-devtools.js

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,4 @@
77
* @flow
88
*/
99

10-
declare module 'events' {
11-
declare class EventEmitter<Events: Object> {
12-
addListener<Event: $Keys<Events>>(
13-
event: Event,
14-
listener: (...$ElementType<Events, Event>) => any,
15-
): void;
16-
emit: <Event: $Keys<Events>>(
17-
event: Event,
18-
...$ElementType<Events, Event>
19-
) => void;
20-
removeListener(event: $Keys<Events>, listener: Function): void;
21-
removeAllListeners(event?: $Keys<Events>): void;
22-
}
23-
24-
declare export default typeof EventEmitter;
25-
}
10+
// No types

0 commit comments

Comments
 (0)