@@ -83,8 +83,10 @@ export type CreateSSEOptions<T> = SSEOptions & {
8383 * - `true`: reconnect with defaults (Infinity retries, 3000ms delay)
8484 * - object: custom `{ retries?, delay? }`
8585 *
86- * Note: `EventSource` already reconnects natively for transient network
87- * drops. This option handles cases where the browser gives up entirely.
86+ * The `retries` budget is shared across both browser-level retries
87+ * (readyState stays CONNECTING) and app-level reconnects (readyState →
88+ * CLOSED). Once the budget is exhausted the connection is fully torn down,
89+ * stopping any further browser-driven retry loops.
8890 */
8991 reconnect ?: boolean | SSEReconnectOptions ;
9092 /**
@@ -227,6 +229,13 @@ export const createSSE = <T = string>(
227229 // ── Connection management ─────────────────────────────────────────────────
228230 let currentCleanup : VoidFunction | undefined ;
229231
232+ /** Tears down the current source without scheduling a reconnect. */
233+ const teardown = ( ) => {
234+ currentCleanup ?.( ) ;
235+ currentCleanup = undefined ;
236+ setSource ( undefined ) ;
237+ } ;
238+
230239 /** Open a fresh connection, resetting the retry counter. */
231240 const connect = ( resolvedUrl : string ) => {
232241 retriesLeft = reconnectConfig . retries ?? 0 ;
@@ -255,16 +264,26 @@ export const createSSE = <T = string>(
255264 setError ( ( ) => e ) ;
256265 options . onError ?.( e ) ;
257266
258- // Only app-level reconnect when the browser has given up (CLOSED).
259- // When readyState is still CONNECTING the browser is handling retries.
267+ // When the browser has given up (CLOSED), perform app-level reconnects
268+ // against the configured budget.
269+ // When the browser is still retrying (CONNECTING) and a reconnect budget
270+ // is configured, count those attempts too so the config is always honoured
271+ // and the browser can never loop infinitely beyond the configured limit.
260272 if ( es . readyState === SSEReadyState . CLOSED && retriesLeft > 0 ) {
261273 retriesLeft -- ;
262274 reconnectTimer = setTimeout ( ( ) => _open ( resolvedUrl ) , reconnectConfig . delay ?? 3000 ) ;
263275 } else if ( es . readyState === SSEReadyState . CLOSED ) {
264276 // Retries exhausted — clean up fully to avoid memory/listener leaks.
265- currentCleanup ?.( ) ;
266- currentCleanup = undefined ;
267- setSource ( undefined ) ;
277+ teardown ( ) ;
278+ } else if ( es . readyState === SSEReadyState . CONNECTING && options . reconnect ) {
279+ // Browser is retrying. Consume the budget; when it's gone, abort so
280+ // we don't loop forever against the user's configured retry limit.
281+ if ( retriesLeft > 0 ) {
282+ retriesLeft -- ;
283+ } else {
284+ teardown ( ) ;
285+ setReadyState ( SSEReadyState . CLOSED ) ;
286+ }
268287 }
269288 } ;
270289
@@ -285,9 +304,7 @@ export const createSSE = <T = string>(
285304 const disconnect = ( ) => {
286305 clearReconnectTimer ( ) ;
287306 retriesLeft = 0 ;
288- currentCleanup ?.( ) ;
289- currentCleanup = undefined ;
290- setSource ( undefined ) ;
307+ teardown ( ) ;
291308 setReadyState ( SSEReadyState . CLOSED ) ;
292309 } ;
293310
@@ -314,10 +331,7 @@ export const createSSE = <T = string>(
314331 const resolvedUrl = url ( ) ;
315332 if ( resolvedUrl !== prevUrl ) {
316333 prevUrl = resolvedUrl ;
317- untrack ( ( ) => {
318- currentCleanup ?.( ) ;
319- currentCleanup = undefined ;
320- } ) ;
334+ untrack ( ( ) => teardown ( ) ) ;
321335 connect ( resolvedUrl ) ;
322336 }
323337 } ) ;
@@ -326,8 +340,7 @@ export const createSSE = <T = string>(
326340 // ── Lifecycle cleanup ─────────────────────────────────────────────────────
327341 onCleanup ( ( ) => {
328342 clearReconnectTimer ( ) ;
329- currentCleanup ?.( ) ;
330- currentCleanup = undefined ;
343+ teardown ( ) ;
331344 } ) ;
332345
333346 return { source, data, error, readyState, close : disconnect , reconnect : manualReconnect } ;
0 commit comments