Skip to content

Commit 2e2270d

Browse files
authored
Merge pull request #5645 from Tyriar/events
Optimize Emitter.fire for 0 and 1 listeners
2 parents 7e3c41b + 042bb4b commit 2e2270d

3 files changed

Lines changed: 158 additions & 4 deletions

File tree

src/common/Event.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
6+
import { assert } from 'chai';
7+
import { Emitter } from 'common/Event';
8+
9+
describe('Emitter', () => {
10+
it('should fire with 0 listeners without error', () => {
11+
const emitter = new Emitter<number>();
12+
emitter.fire(42);
13+
});
14+
15+
it('should fire with 1 listener', () => {
16+
const emitter = new Emitter<number>();
17+
let received: number | undefined;
18+
emitter.event(e => { received = e; });
19+
emitter.fire(42);
20+
assert.strictEqual(received, 42);
21+
});
22+
23+
it('should fire with 1 listener using thisArgs', () => {
24+
const emitter = new Emitter<number>();
25+
const obj = { value: 0, handler(e: number) { this.value = e; } };
26+
emitter.event(obj.handler, obj);
27+
emitter.fire(42);
28+
assert.strictEqual(obj.value, 42);
29+
});
30+
31+
it('should fire with multiple listeners', () => {
32+
const emitter = new Emitter<number>();
33+
const results: number[] = [];
34+
emitter.event(e => results.push(e * 1));
35+
emitter.event(e => results.push(e * 2));
36+
emitter.event(e => results.push(e * 3));
37+
emitter.fire(10);
38+
assert.deepEqual(results, [10, 20, 30]);
39+
});
40+
41+
it('should handle listener removal during fire', () => {
42+
const emitter = new Emitter<number>();
43+
const results: string[] = [];
44+
emitter.event(() => results.push('first'));
45+
const disposable = emitter.event(() => {
46+
results.push('second');
47+
disposable.dispose();
48+
});
49+
emitter.event(() => results.push('third'));
50+
emitter.fire(1);
51+
assert.deepEqual(results, ['first', 'second', 'third']);
52+
});
53+
54+
it('should not fire after dispose', () => {
55+
const emitter = new Emitter<number>();
56+
let called = false;
57+
emitter.event(() => { called = true; });
58+
emitter.dispose();
59+
emitter.fire(42);
60+
assert.strictEqual(called, false);
61+
});
62+
63+
it('should allow disposing a listener', () => {
64+
const emitter = new Emitter<number>();
65+
let count = 0;
66+
const disposable = emitter.event(() => { count++; });
67+
emitter.fire(1);
68+
disposable.dispose();
69+
emitter.fire(2);
70+
assert.strictEqual(count, 1);
71+
});
72+
});

src/common/Event.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,20 @@ export class Emitter<T> {
5353
if (this._disposed) {
5454
return;
5555
}
56-
// Snapshot listeners to allow modifications during iteration
57-
const listeners = this._listeners.slice();
58-
for (const { fn, thisArgs } of listeners) {
59-
fn.call(thisArgs, event);
56+
switch (this._listeners.length) {
57+
case 0: return;
58+
case 1: {
59+
const { fn, thisArgs } = this._listeners[0];
60+
fn.call(thisArgs, event);
61+
return;
62+
}
63+
default: {
64+
// Snapshot listeners to allow modifications during iteration (2+ listeners)
65+
const listeners = this._listeners.slice();
66+
for (const { fn, thisArgs } of listeners) {
67+
fn.call(thisArgs, event);
68+
}
69+
}
6070
}
6171
}
6272

test/benchmark/Event.benchmark.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
6+
import { perfContext, before, ThroughputRuntimeCase } from 'xterm-benchmark';
7+
import { Emitter } from 'common/Event';
8+
9+
const ITERATIONS = 1_000_000;
10+
11+
perfContext('Emitter.fire()', () => {
12+
perfContext('0 listeners', () => {
13+
let emitter: Emitter<number>;
14+
before(() => {
15+
emitter = new Emitter<number>();
16+
});
17+
new ThroughputRuntimeCase('', () => {
18+
for (let i = 0; i < ITERATIONS; i++) {
19+
emitter.fire(i);
20+
}
21+
return { payloadSize: ITERATIONS };
22+
}, { fork: false }).showAverageThroughput();
23+
});
24+
25+
perfContext('1 listener', () => {
26+
let emitter: Emitter<number>;
27+
let sum = 0;
28+
before(() => {
29+
emitter = new Emitter<number>();
30+
emitter.event(e => { sum += e; });
31+
});
32+
new ThroughputRuntimeCase('', () => {
33+
for (let i = 0; i < ITERATIONS; i++) {
34+
emitter.fire(i);
35+
}
36+
return { payloadSize: ITERATIONS };
37+
}, { fork: false }).showAverageThroughput();
38+
});
39+
40+
perfContext('2 listeners', () => {
41+
let emitter: Emitter<number>;
42+
let sum = 0;
43+
before(() => {
44+
emitter = new Emitter<number>();
45+
emitter.event(e => { sum += e; });
46+
emitter.event(e => { sum += e * 2; });
47+
});
48+
new ThroughputRuntimeCase('', () => {
49+
for (let i = 0; i < ITERATIONS; i++) {
50+
emitter.fire(i);
51+
}
52+
return { payloadSize: ITERATIONS };
53+
}, { fork: false }).showAverageThroughput();
54+
});
55+
56+
perfContext('5 listeners', () => {
57+
let emitter: Emitter<number>;
58+
let sum = 0;
59+
before(() => {
60+
emitter = new Emitter<number>();
61+
for (let j = 0; j < 5; j++) {
62+
emitter.event(e => { sum += e; });
63+
}
64+
});
65+
new ThroughputRuntimeCase('', () => {
66+
for (let i = 0; i < ITERATIONS; i++) {
67+
emitter.fire(i);
68+
}
69+
return { payloadSize: ITERATIONS };
70+
}, { fork: false }).showAverageThroughput();
71+
});
72+
});

0 commit comments

Comments
 (0)