Skip to content

Commit 3fe0d3f

Browse files
authored
Merge pull request #12955 from CesiumGS/event-fix
Fixes event bug with changes to listeners during callback
2 parents b5b3417 + 91a202a commit 3fe0d3f

File tree

3 files changed

+115
-27
lines changed

3 files changed

+115
-27
lines changed

CHANGES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Change Log
22

3+
## 1.135 - 2025-11-01
4+
5+
### @cesium/engine
6+
7+
#### Fixes :wrench:
8+
9+
- Fixes an event bug following recent changes, where adding a new listener during an event callback caused an infinite loop. [#12955](https://github.com/CesiumGS/cesium/pull/12955)
10+
311
## 1.134 - 2025-10-01
412

513
- [Sandcastle](https://sandcastle.cesium.com/) has been updated at `https://sandcastle.cesium.com`! The [legacy Sandcastle app](https://cesium.com/downloads/cesiumjs/releases/1.134/Apps/Sandcastle/index.html) will remain available through November 3, 2025.

packages/engine/Source/Core/Event.js

Lines changed: 76 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,17 @@ function Event() {
2727
* @private
2828
*/
2929
this._listeners = new Map();
30-
this._toRemove = [];
31-
this._insideRaiseEvent = false;
30+
/**
31+
* @type {Map<Listener,Set<object>>}
32+
* @private
33+
*/
34+
this._toRemove = new Map();
35+
/**
36+
* @type {Map<Listener,Set<object>>}
37+
* @private
38+
*/
39+
this._toAdd = new Map();
40+
this._invokingListeners = false;
3241
this._listenerCount = 0; // Tracks number of listener + scope pairs
3342
}
3443

@@ -63,22 +72,34 @@ Event.prototype.addEventListener = function (listener, scope) {
6372
//>>includeStart('debug', pragmas.debug);
6473
Check.typeOf.func("listener", listener);
6574
//>>includeEnd('debug');
75+
const event = this;
6676

67-
if (!this._listeners.has(listener)) {
68-
this._listeners.set(listener, new Set());
69-
}
70-
const scopes = this._listeners.get(listener);
71-
if (!scopes.has(scope)) {
72-
scopes.add(scope);
73-
this._listenerCount++;
77+
const listenerMap = event._invokingListeners
78+
? event._toAdd
79+
: event._listeners;
80+
const added = addEventListener(this, listenerMap, listener, scope);
81+
if (added) {
82+
event._listenerCount++;
7483
}
7584

76-
const event = this;
7785
return function () {
7886
event.removeEventListener(listener, scope);
7987
};
8088
};
8189

90+
function addEventListener(event, listenerMap, listener, scope) {
91+
if (!listenerMap.has(listener)) {
92+
listenerMap.set(listener, new Set());
93+
}
94+
const scopes = listenerMap.get(listener);
95+
if (!scopes.has(scope)) {
96+
scopes.add(scope);
97+
return true;
98+
}
99+
100+
return false;
101+
}
102+
82103
/**
83104
* Unregisters a previously registered callback.
84105
*
@@ -94,23 +115,47 @@ Event.prototype.removeEventListener = function (listener, scope) {
94115
Check.typeOf.func("listener", listener);
95116
//>>includeEnd('debug');
96117

97-
const scopes = this._listeners.get(listener);
118+
const removedFromListeners = removeEventListener(
119+
this,
120+
this._listeners,
121+
listener,
122+
scope,
123+
);
124+
const removedFromToAdd = removeEventListener(
125+
this,
126+
this._toAdd,
127+
listener,
128+
scope,
129+
);
130+
131+
const removed = removedFromListeners || removedFromToAdd;
132+
if (removed) {
133+
this._listenerCount--;
134+
}
135+
136+
return removed;
137+
};
138+
139+
function removeEventListener(event, listenerMap, listener, scope) {
140+
const scopes = listenerMap.get(listener);
98141
if (!scopes || !scopes.has(scope)) {
99142
return false;
100143
}
101144

102-
if (this._insideRaiseEvent) {
103-
this._toRemove.push({ listener, scope });
145+
if (event._invokingListeners) {
146+
if (!addEventListener(event, event._toRemove, listener, scope)) {
147+
// Already marked for removal
148+
return false;
149+
}
104150
} else {
105151
scopes.delete(scope);
106152
if (scopes.size === 0) {
107-
this._listeners.delete(listener);
153+
listenerMap.delete(listener);
108154
}
109155
}
110156

111-
this._listenerCount--;
112157
return true;
113-
};
158+
}
114159

115160
/**
116161
* Raises the event by calling each registered listener with all supplied arguments.
@@ -121,7 +166,7 @@ Event.prototype.removeEventListener = function (listener, scope) {
121166
* @see Event#removeEventListener
122167
*/
123168
Event.prototype.raiseEvent = function () {
124-
this._insideRaiseEvent = true;
169+
this._invokingListeners = true;
125170

126171
for (const [listener, scopes] of this._listeners.entries()) {
127172
if (!defined(listener)) {
@@ -133,19 +178,23 @@ Event.prototype.raiseEvent = function () {
133178
}
134179
}
135180

136-
// Actually remove items marked for removal
137-
if (this._toRemove.length > 0) {
138-
for (const { listener, scope } of this._toRemove) {
139-
const scopes = this._listeners.get(listener);
140-
scopes.delete(scope);
141-
if (scopes.size === 0) {
142-
this._listeners.delete(listener);
143-
}
181+
this._invokingListeners = false;
182+
183+
// Actually add items marked for addition
184+
for (const [listener, scopes] of this._toAdd.entries()) {
185+
for (const scope of scopes) {
186+
addEventListener(this, this._listeners, listener, scope);
144187
}
145-
this._toRemove.length = 0;
146188
}
189+
this._toAdd.clear();
147190

148-
this._insideRaiseEvent = false;
191+
// Actually remove items marked for removal
192+
for (const [listener, scopes] of this._toRemove.entries()) {
193+
for (const scope of scopes) {
194+
removeEventListener(this, this._listeners, listener, scope);
195+
}
196+
}
197+
this._toRemove.clear();
149198
};
150199

151200
/**

packages/engine/Specs/Core/EventSpec.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,37 @@ describe("Core/Event", function () {
9191
expect(event.numberOfListeners).toEqual(5);
9292
});
9393

94+
it("can add a listener from within a callback", function () {
95+
const doNothing = function (evt) {};
96+
97+
const addEventCb = function (evt) {
98+
event.addEventListener(doNothing);
99+
};
100+
101+
event.addEventListener(addEventCb);
102+
event.raiseEvent();
103+
expect(event.numberOfListeners).toEqual(2);
104+
105+
event.removeEventListener(doNothing);
106+
event.removeEventListener(addEventCb);
107+
expect(event.numberOfListeners).toEqual(0);
108+
});
109+
110+
it("can add multiple listeners within a callback", function () {
111+
const addEvent0 = function () {
112+
event.addEventListener(function () {});
113+
};
114+
const addEvent1 = function () {
115+
event.addEventListener(function () {});
116+
};
117+
118+
event.addEventListener(addEvent0);
119+
event.addEventListener(addEvent1);
120+
expect(event.numberOfListeners).toEqual(2);
121+
event.raiseEvent();
122+
expect(event.numberOfListeners).toEqual(4);
123+
});
124+
94125
it("addEventListener and removeEventListener works with same function of different scopes", function () {
95126
const Scope = function () {
96127
this.timesCalled = 0;

0 commit comments

Comments
 (0)