@@ -82,7 +82,8 @@ export class OneWayWebSocket<TData = unknown>
82
82
implements OneWayWebSocketApi < TData >
83
83
{
84
84
readonly #socket:WebSocket ;
85
- readonly #messageCallbackWrappers= new Map <
85
+ readonly #errorListeners= new Set < ( e :Event ) => void > ( ) ;
86
+ readonly #messageListenerWrappers= new Map <
86
87
OneWayEventCallback < TData , "message" > ,
87
88
WebSocketMessageCallback
88
89
> ( ) ;
@@ -98,7 +99,7 @@ export class OneWayWebSocket<TData = unknown>
98
99
} = init ;
99
100
100
101
if ( ! apiRoute . startsWith ( "/api/v2/" ) ) {
101
- throw new Error ( `API route '${ apiRoute } ' does not begin witha slash ` ) ;
102
+ throw new Error ( `API route '${ apiRoute } ' does not begin with'/api/v2/' ` ) ;
102
103
}
103
104
104
105
const formattedParams =
@@ -122,6 +123,10 @@ export class OneWayWebSocket<TData = unknown>
122
123
event :TEvent ,
123
124
callback :OneWayEventCallback < TData , TEvent > ,
124
125
) :void {
126
+ if ( this . #socket. readyState === WebSocket . CLOSED ) {
127
+ return ;
128
+ }
129
+
125
130
// Not happy about all the type assertions, but there are some nasty
126
131
// type contravariance issues if you try to resolve the function types
127
132
// properly. This is actually the lesser of two evils
@@ -130,11 +135,16 @@ export class OneWayWebSocket<TData = unknown>
130
135
WebSocketEventType
131
136
> ;
132
137
133
- if ( this . #messageCallbackWrappers. has ( looseCallback ) ) {
138
+ // WebSockets automatically handle de-duping callbacks, but we have to
139
+ // do a separate check for the wrappers
140
+ if ( this . #messageListenerWrappers. has ( looseCallback ) ) {
134
141
return ;
135
142
}
136
143
if ( event !== "message" ) {
137
144
this . #socket. addEventListener ( event , looseCallback ) ;
145
+ if ( event === "error" ) {
146
+ this . #errorListeners. add ( looseCallback ) ;
147
+ }
138
148
return ;
139
149
}
140
150
@@ -161,7 +171,7 @@ export class OneWayWebSocket<TData = unknown>
161
171
} ;
162
172
163
173
this . #socket. addEventListener ( event as "message" , wrapped ) ;
164
- this . #messageCallbackWrappers . set ( looseCallback , wrapped ) ;
174
+ this . #messageListenerWrappers . set ( looseCallback , wrapped ) ;
165
175
}
166
176
167
177
removeEventListener < TEvent extends WebSocketEventType > (
@@ -175,24 +185,37 @@ export class OneWayWebSocket<TData = unknown>
175
185
176
186
if ( event !== "message" ) {
177
187
this . #socket. removeEventListener ( event , looseCallback ) ;
188
+ if ( event === "error" ) {
189
+ this . #errorListeners. delete ( looseCallback ) ;
190
+ }
178
191
return ;
179
192
}
180
- if ( ! this . #messageCallbackWrappers . has ( looseCallback ) ) {
193
+ if ( ! this . #messageListenerWrappers . has ( looseCallback ) ) {
181
194
return ;
182
195
}
183
196
184
- const wrapper = this . #messageCallbackWrappers . get ( looseCallback ) ;
197
+ const wrapper = this . #messageListenerWrappers . get ( looseCallback ) ;
185
198
if ( wrapper === undefined ) {
186
199
throw new Error (
187
200
`Cannot unregister callback for event${ event } . This is likely an issue with the browser itself.` ,
188
201
) ;
189
202
}
190
203
191
204
this . #socket. removeEventListener ( event as "message" , wrapper ) ;
192
- this . #messageCallbackWrappers . delete ( looseCallback ) ;
205
+ this . #messageListenerWrappers . delete ( looseCallback ) ;
193
206
}
194
207
195
208
close ( closeCode ?:number , reason ?:string ) :void {
209
+ // Eject all error event listeners, mainly for ergonomics in React dev
210
+ // mode. React's StrictMode will create additional connections to ensure
211
+ // there aren't any render bugs, but manually closing a connection via a
212
+ // cleanup function sometimes causes error events to get dispatched for
213
+ // a connection that is no longer wired up to the UI
214
+ for ( const cb of this . #errorListeners) {
215
+ this . #socket. removeEventListener ( "error" , cb ) ;
216
+ this . #errorListeners. delete ( cb ) ;
217
+ }
218
+
196
219
this . #socket. close ( closeCode , reason ) ;
197
220
}
198
221
}