@flamingo-stack/openframe-frontend-core 0.0.554 → 0.0.555-snapshot.20260818112641

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/{chunk-P6LUXP5E.js → chunk-5756Q6UN.js} +3 -3
  2. package/dist/{chunk-44QNFLHC.js → chunk-5EES5HD4.js} +6 -6
  3. package/dist/{chunk-VIDE4YOQ.js → chunk-5JWLZJK5.js} +2 -2
  4. package/dist/{chunk-RMGX6BVQ.js → chunk-5M4PS3XX.js} +3 -3
  5. package/dist/{chunk-4KIPZULI.cjs → chunk-7Q7GFIFB.cjs} +7 -7
  6. package/dist/{chunk-4KIPZULI.cjs.map → chunk-7Q7GFIFB.cjs.map} +1 -1
  7. package/dist/{chunk-JJKK7E66.cjs → chunk-BNMKHG2L.cjs} +4 -4
  8. package/dist/{chunk-JJKK7E66.cjs.map → chunk-BNMKHG2L.cjs.map} +1 -1
  9. package/dist/{chunk-ULH6JHWZ.cjs → chunk-BZKUID4R.cjs} +14 -14
  10. package/dist/{chunk-ULH6JHWZ.cjs.map → chunk-BZKUID4R.cjs.map} +1 -1
  11. package/dist/{chunk-CWLV6DEK.cjs → chunk-C74PZU6M.cjs} +32 -32
  12. package/dist/{chunk-CWLV6DEK.cjs.map → chunk-C74PZU6M.cjs.map} +1 -1
  13. package/dist/{chunk-67Q4QQBA.js → chunk-CGUSNV6R.js} +8 -8
  14. package/dist/{chunk-PAW27EWH.cjs → chunk-CRGIXNUE.cjs} +23 -23
  15. package/dist/{chunk-PAW27EWH.cjs.map → chunk-CRGIXNUE.cjs.map} +1 -1
  16. package/dist/{chunk-Q6RC3YGW.cjs → chunk-DFJ7TWH6.cjs} +3 -3
  17. package/dist/{chunk-Q6RC3YGW.cjs.map → chunk-DFJ7TWH6.cjs.map} +1 -1
  18. package/dist/{chunk-5VICQQM7.js → chunk-FLSEJSTN.js} +2 -2
  19. package/dist/{chunk-PDVSL7OW.js → chunk-FZT7BYNF.js} +2 -2
  20. package/dist/{chunk-J355HL3X.cjs → chunk-HOEOGFQM.cjs} +97 -97
  21. package/dist/{chunk-J355HL3X.cjs.map → chunk-HOEOGFQM.cjs.map} +1 -1
  22. package/dist/{chunk-HO6IG73U.cjs → chunk-HYABNMBN.cjs} +76 -76
  23. package/dist/{chunk-HO6IG73U.cjs.map → chunk-HYABNMBN.cjs.map} +1 -1
  24. package/dist/{chunk-QH3QWY3N.cjs → chunk-ICG7JRYT.cjs} +40 -40
  25. package/dist/{chunk-QH3QWY3N.cjs.map → chunk-ICG7JRYT.cjs.map} +1 -1
  26. package/dist/{chunk-CGCQ4HA5.cjs → chunk-JDUPAHJR.cjs} +18 -18
  27. package/dist/{chunk-CGCQ4HA5.cjs.map → chunk-JDUPAHJR.cjs.map} +1 -1
  28. package/dist/{chunk-QA7NU73D.js → chunk-JRWOHH7K.js} +2 -2
  29. package/dist/{chunk-FHS2SA5F.js → chunk-JXJ4FTKN.js} +3 -3
  30. package/dist/{chunk-AQFYI3HH.cjs → chunk-LZD3VSPS.cjs} +12 -12
  31. package/dist/{chunk-AQFYI3HH.cjs.map → chunk-LZD3VSPS.cjs.map} +1 -1
  32. package/dist/{chunk-WZWQWZFJ.cjs → chunk-Q5SZ2QTZ.cjs} +42 -42
  33. package/dist/{chunk-WZWQWZFJ.cjs.map → chunk-Q5SZ2QTZ.cjs.map} +1 -1
  34. package/dist/{chunk-GSONSIWG.js → chunk-R7A7WHXP.js} +60 -35
  35. package/dist/chunk-R7A7WHXP.js.map +1 -0
  36. package/dist/{chunk-2HWN6CYI.cjs → chunk-S4LL6QI6.cjs} +176 -168
  37. package/dist/chunk-S4LL6QI6.cjs.map +1 -0
  38. package/dist/{chunk-KQKU4VXB.cjs → chunk-STHVHAV4.cjs} +82 -57
  39. package/dist/chunk-STHVHAV4.cjs.map +1 -0
  40. package/dist/{chunk-PFQVCHDE.cjs → chunk-SXMJLNOF.cjs} +3 -3
  41. package/dist/{chunk-PFQVCHDE.cjs.map → chunk-SXMJLNOF.cjs.map} +1 -1
  42. package/dist/{chunk-SBXQW72U.js → chunk-VF4DCOPD.js} +5 -5
  43. package/dist/{chunk-QTO3WYB2.cjs → chunk-VJTD2DGF.cjs} +3 -3
  44. package/dist/{chunk-QTO3WYB2.cjs.map → chunk-VJTD2DGF.cjs.map} +1 -1
  45. package/dist/{chunk-VGLDA77S.js → chunk-VRX6Y6A7.js} +2 -2
  46. package/dist/{chunk-U72YYXBT.js → chunk-VXNMOGRP.js} +6 -6
  47. package/dist/{chunk-OR67WC32.js → chunk-WBQ3NOH7.js} +2 -2
  48. package/dist/{chunk-DXAUNMFM.js → chunk-WX5UCLL7.js} +2 -2
  49. package/dist/{chunk-ZGLMZM2I.js → chunk-XGBVHQP7.js} +32 -24
  50. package/dist/{chunk-ZGLMZM2I.js.map → chunk-XGBVHQP7.js.map} +1 -1
  51. package/dist/{chunk-FVXND3SO.js → chunk-YM2WHYYK.js} +4 -4
  52. package/dist/{chunk-XMB5P2OS.cjs → chunk-Z3R3LOC5.cjs} +5 -5
  53. package/dist/{chunk-XMB5P2OS.cjs.map → chunk-Z3R3LOC5.cjs.map} +1 -1
  54. package/dist/components/case-studies/index.cjs +10 -10
  55. package/dist/components/case-studies/index.js +4 -4
  56. package/dist/components/chat/index.cjs +4 -4
  57. package/dist/components/chat/index.js +3 -3
  58. package/dist/components/chat/stream/chat-stream-reducer.d.ts +1 -1
  59. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  60. package/dist/components/chat/utils/message-segment-accumulator.d.ts +7 -5
  61. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  62. package/dist/components/contact/index.cjs +5 -5
  63. package/dist/components/contact/index.js +4 -4
  64. package/dist/components/docs/index.cjs +7 -7
  65. package/dist/components/docs/index.js +6 -6
  66. package/dist/components/embeds/index.cjs +5 -5
  67. package/dist/components/embeds/index.js +4 -4
  68. package/dist/components/faq/index.cjs +6 -6
  69. package/dist/components/faq/index.js +5 -5
  70. package/dist/components/features/index.cjs +4 -4
  71. package/dist/components/features/index.js +3 -3
  72. package/dist/components/help-center-pages/index.cjs +25 -25
  73. package/dist/components/help-center-pages/index.js +16 -16
  74. package/dist/components/index.cjs +138 -138
  75. package/dist/components/index.js +14 -14
  76. package/dist/components/meeting-scheduler/index.cjs +35 -35
  77. package/dist/components/meeting-scheduler/index.js +5 -5
  78. package/dist/components/navigation/index.cjs +4 -4
  79. package/dist/components/navigation/index.js +3 -3
  80. package/dist/components/onboarding-guides/index.cjs +8 -8
  81. package/dist/components/onboarding-guides/index.js +7 -7
  82. package/dist/components/related-content/index.cjs +6 -6
  83. package/dist/components/related-content/index.js +5 -5
  84. package/dist/components/tickets/index.cjs +8 -8
  85. package/dist/components/tickets/index.js +7 -7
  86. package/dist/components/ui/index.cjs +4 -4
  87. package/dist/components/ui/index.js +3 -3
  88. package/dist/hooks/index.cjs +3 -3
  89. package/dist/hooks/index.js +2 -2
  90. package/dist/index.cjs +4 -4
  91. package/dist/index.js +3 -3
  92. package/dist/nats/index.cjs +2 -2
  93. package/dist/nats/index.js +1 -1
  94. package/dist/nats/nats.d.ts.map +1 -1
  95. package/dist/nats/shared-connection.d.ts +13 -6
  96. package/dist/nats/shared-connection.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/components/chat/__tests__/ticket-event.test.ts +52 -0
  99. package/src/components/chat/stream/chat-stream-reducer.ts +15 -11
  100. package/src/components/chat/utils/message-segment-accumulator.ts +31 -17
  101. package/src/nats/__tests__/reconnect-lifecycle.test.tsx +814 -0
  102. package/src/nats/nats.ts +56 -13
  103. package/src/nats/shared-connection.ts +132 -36
  104. package/dist/chunk-2HWN6CYI.cjs.map +0 -1
  105. package/dist/chunk-GSONSIWG.js.map +0 -1
  106. package/dist/chunk-KQKU4VXB.cjs.map +0 -1
  107. /package/dist/{chunk-P6LUXP5E.js.map → chunk-5756Q6UN.js.map} +0 -0
  108. /package/dist/{chunk-44QNFLHC.js.map → chunk-5EES5HD4.js.map} +0 -0
  109. /package/dist/{chunk-VIDE4YOQ.js.map → chunk-5JWLZJK5.js.map} +0 -0
  110. /package/dist/{chunk-RMGX6BVQ.js.map → chunk-5M4PS3XX.js.map} +0 -0
  111. /package/dist/{chunk-67Q4QQBA.js.map → chunk-CGUSNV6R.js.map} +0 -0
  112. /package/dist/{chunk-5VICQQM7.js.map → chunk-FLSEJSTN.js.map} +0 -0
  113. /package/dist/{chunk-PDVSL7OW.js.map → chunk-FZT7BYNF.js.map} +0 -0
  114. /package/dist/{chunk-QA7NU73D.js.map → chunk-JRWOHH7K.js.map} +0 -0
  115. /package/dist/{chunk-FHS2SA5F.js.map → chunk-JXJ4FTKN.js.map} +0 -0
  116. /package/dist/{chunk-SBXQW72U.js.map → chunk-VF4DCOPD.js.map} +0 -0
  117. /package/dist/{chunk-VGLDA77S.js.map → chunk-VRX6Y6A7.js.map} +0 -0
  118. /package/dist/{chunk-U72YYXBT.js.map → chunk-VXNMOGRP.js.map} +0 -0
  119. /package/dist/{chunk-OR67WC32.js.map → chunk-WBQ3NOH7.js.map} +0 -0
  120. /package/dist/{chunk-DXAUNMFM.js.map → chunk-WX5UCLL7.js.map} +0 -0
  121. /package/dist/{chunk-FVXND3SO.js.map → chunk-YM2WHYYK.js.map} +0 -0
package/src/nats/nats.ts CHANGED
@@ -3,6 +3,8 @@ import type {
3
3
  Consumer,
4
4
  ConsumerEvents,
5
5
  ConsumerMessages,
6
+ DebugEvents,
7
+ Events,
6
8
  JsMsg,
7
9
  MsgHdrs as NatsHeaders,
8
10
  Msg,
@@ -278,14 +280,58 @@ function mapOptionsToConnectionOptions(
278
280
  }
279
281
  }
280
282
 
281
- function mapNatsTypeToStatus(type: unknown): NatsStatus | null {
282
- const t = String(type).toLowerCase()
283
- if (t.includes('disconnect')) return 'disconnected'
284
- if (t === 'reconnecting') return 'reconnecting'
285
- if (t === 'reconnect' || t.includes('connect')) return 'connected'
286
- if (t.includes('error')) return 'error'
287
- if (t.includes('close')) return 'closed'
288
- return null
283
+ /**
284
+ * Map a nats.ws status event onto our own status vocabulary.
285
+ *
286
+ * Exact enum matches, never substring tests. Substring matching reported
287
+ * `staleConnection` and `client initiated reconnect` as CONNECTED — both
288
+ * contain "connect" and neither contains "disconnect" — so the wrapper
289
+ * announced a live connection at the exact moment the client had given up on
290
+ * one. Downstream that is worse than silence: `NatsProvider` reads a
291
+ * `connected` that follows an earlier connection as a RECONNECT and bumps
292
+ * `reconnectionCount`, which every subscriber takes as "the tail came back" —
293
+ * resubscribing against a dying client and refetching persisted history.
294
+ * `staleConnection` is the one an idle mobile app hits routinely: no pong
295
+ * within `pingInterval * maxPingOut`, i.e. after a spell in the background.
296
+ *
297
+ * `StaleConnection` maps to `disconnected` rather than being ignored on the
298
+ * grounds that `Disconnect` follows it anyway — it does not always. nats.ws
299
+ * tears the transport down by draining `bufferedAmount` first, and a
300
+ * black-holed socket (no FIN, which is how a mobile link usually dies) never
301
+ * drains, so `Disconnect` can fail to arrive at all. Reporting it is then the
302
+ * only honest answer available: consumers stop trusting a tail the client has
303
+ * already given up on. It does NOT by itself recover that connection — the
304
+ * retry it arms short-circuits, because a protocol that never closed still
305
+ * reports `isConnected()`. Getting the socket back from that state needs a
306
+ * force-close path the client does not currently expose.
307
+ *
308
+ * The enums arrive as arguments rather than through a module-scope import
309
+ * because `nats.ws` is loaded lazily (see {@link importNats}) to keep this
310
+ * module safe to import from a Next server bundle. A hand-copied literal is
311
+ * what this is undoing, so it is not reintroduced here.
312
+ */
313
+ function mapNatsTypeToStatus(
314
+ type: Events | DebugEvents,
315
+ events: typeof import('nats.ws').Events,
316
+ debugEvents: typeof import('nats.ws').DebugEvents,
317
+ ): NatsStatus | null {
318
+ switch (type) {
319
+ case events.Disconnect:
320
+ return 'disconnected'
321
+ case events.Reconnect:
322
+ return 'connected'
323
+ case events.Error:
324
+ return 'error'
325
+ case debugEvents.Reconnecting:
326
+ case debugEvents.ClientInitiatedReconnect:
327
+ return 'reconnecting'
328
+ case debugEvents.StaleConnection:
329
+ return 'disconnected'
330
+ // Events.Update (cluster gossip), Events.LDM and DebugEvents.PingTimer say
331
+ // nothing about reachability.
332
+ default:
333
+ return null
334
+ }
289
335
  }
290
336
 
291
337
  /**
@@ -372,15 +418,12 @@ export function createNatsClient(options: NatsClientOptions): NatsClient {
372
418
  try {
373
419
  for await (const s of conn.status()) {
374
420
  if (signal.aborted) return
375
- const mapped = mapNatsTypeToStatus((s as any)?.type)
421
+ const mapped = mapNatsTypeToStatus(s.type, nats.Events, nats.DebugEvents)
376
422
  if (mapped) {
377
423
  if (mapped === 'connected' && backoff) {
378
424
  backoff.reset()
379
425
  }
380
- emitStatus({ status: mapped, data: (s as any)?.data })
381
- if (mapped === 'closed') {
382
- nc = null
383
- }
426
+ emitStatus({ status: mapped, data: s.data })
384
427
  }
385
428
  }
386
429
  } catch (e) {
@@ -26,17 +26,24 @@ export interface NatsReconnectionBackoff {
26
26
  export interface SharedConnection {
27
27
  wsUrl: string
28
28
  client: NatsClient
29
- connectPromise: Promise<void> | null
30
29
  refCount: number
31
30
  closeTimer: ReturnType<typeof setTimeout> | null
32
31
  retryTimer: ReturnType<typeof setTimeout> | null
33
32
  /**
34
- * Identity of the consumer driving reconnect. When set, other consumers
35
- * should observe status only and skip their own scheduleRetry — otherwise
36
- * each disconnect triggers multiple concurrent connect() calls racing over
37
- * connectPromise.
33
+ * The lifecycle driving reconnect, held as its own `scheduleRetry`. When set,
34
+ * other consumers observe status only and skip their own scheduleRetry —
35
+ * otherwise every disconnect starts one backoff schedule per attached
36
+ * consumer, all dialling the same connection on their own clocks.
38
37
  */
39
- retryOwner: object | null
38
+ retryOwner: (() => void) | null
39
+ /**
40
+ * The `scheduleRetry` of every lifecycle currently observing this connection,
41
+ * in attach order — the same value that goes into `retryOwner`, so a
42
+ * lifecycle has one identity rather than two. Only the owner drives
43
+ * reconnect, so when it gives the loop up the roster is what a successor is
44
+ * found in; see {@link handOffRetry}.
45
+ */
46
+ retrySchedulers: Set<() => void>
40
47
  }
41
48
 
42
49
  export interface AcquireClientOptions {
@@ -99,11 +106,11 @@ export function acquireClient(url: string, opts?: AcquireClientOptions): SharedC
99
106
  conn = {
100
107
  wsUrl: url,
101
108
  client,
102
- connectPromise: null,
103
109
  refCount: 0,
104
110
  closeTimer: null,
105
111
  retryTimer: null,
106
112
  retryOwner: null,
113
+ retrySchedulers: new Set(),
107
114
  }
108
115
  connections.set(url, conn)
109
116
  }
@@ -146,11 +153,13 @@ export function getSharedConnectionFor(url: string | null | undefined): SharedCo
146
153
 
147
154
  // ---------------------------------------------------------------------------
148
155
  // Connection lifecycle: retry + status loop, shared by NatsProvider and the
149
- // chat hooks. Each consumer that wants to drive reconnect creates an
150
- // ownerToken and calls startConnectionLifecycle(). The first caller to claim
151
- // retryOwner runs the actual retry loop; subsequent claimants observe status
152
- // only. When the owner unmounts and releases retryOwner, the next status
153
- // event lets a surviving consumer pick up ownership opportunistically.
156
+ // chat hooks. Every consumer that wants to observe or drive reconnect calls
157
+ // startConnectionLifecycle(). The first to claim retryOwner runs the actual
158
+ // retry loop; later attachers observe status only. When the owner gives the
159
+ // loop up it is handed to a survivor if the connection still needs one; while
160
+ // the connection is healthy no handoff is needed, because the next status
161
+ // event lets a survivor claim ownership opportunistically inside
162
+ // scheduleRetry.
154
163
  // ---------------------------------------------------------------------------
155
164
 
156
165
  export interface ConnectionLifecycleOptions {
@@ -176,14 +185,30 @@ export interface ConnectionLifecycleHandle {
176
185
 
177
186
  const defaultShouldRetryOn = (status: NatsStatus) => status === 'closed' || status === 'disconnected'
178
187
 
188
+ /**
189
+ * Offer the retry loop to another lifecycle on this connection.
190
+ *
191
+ * A retry is only ever armed from a status event or a failed dial, and a
192
+ * connection that is already down produces neither on its own — so whenever
193
+ * the owner gives the loop up while the connection still needs dialling, a
194
+ * successor has to be pushed rather than left waiting for an event that is not
195
+ * coming.
196
+ *
197
+ * Candidates that cannot service this URL decline inside `scheduleRetry`, so
198
+ * this walks the roster until one actually takes it. Callers must clear any
199
+ * timer of their own first: a successor that claims while a timer is still
200
+ * armed will not arm one, and the departing owner's timer no longer speaks for
201
+ * the connection.
202
+ */
203
+ function handOffRetry(conn: SharedConnection): void {
204
+ for (const takeOver of conn.retrySchedulers) {
205
+ takeOver()
206
+ if (conn.retryOwner) return
207
+ }
208
+ }
209
+
179
210
  export function startConnectionLifecycle(options: ConnectionLifecycleOptions): ConnectionLifecycleHandle {
180
211
  const { conn, wsUrl } = options
181
- // Each lifecycle gets its own identity for retry ownership. The first lifecycle to claim
182
- // an unowned connection drives reconnect; later attachers observe status only until the
183
- // owner releases (see opportunistic claim in scheduleRetry).
184
- const ownerToken = {}
185
- if (!conn.retryOwner) conn.retryOwner = ownerToken
186
-
187
212
  let closed = false
188
213
  let retryAttempt = 0
189
214
 
@@ -195,16 +220,33 @@ export function startConnectionLifecycle(options: ConnectionLifecycleOptions): C
195
220
  }
196
221
  }
197
222
 
223
+ // A lifecycle's identity IS its `scheduleRetry`: that function goes on the
224
+ // connection's roster and, for whichever lifecycle holds the loop, into
225
+ // `retryOwner` — one identity, not two.
226
+ conn.retrySchedulers.add(scheduleRetry)
227
+ if (!conn.retryOwner) conn.retryOwner = scheduleRetry
228
+
198
229
  function scheduleRetry() {
199
230
  if (closed) return
200
231
  if (getSharedConnectionFor(wsUrl) !== conn) return
201
- if (!conn.retryOwner) conn.retryOwner = ownerToken
202
- if (conn.retryOwner !== ownerToken) return
203
-
204
- if (conn.retryTimer) {
205
- clearTimeout(conn.retryTimer)
206
- conn.retryTimer = null
207
- }
232
+ // This lifecycle now wants a different URL, so it must not drive this
233
+ // connection — not from its own status events, and not as a handoff
234
+ // successor. Without the guard, two consumers that had both moved on
235
+ // passed ownership back and forth indefinitely, running onBeforeReconnect
236
+ // — a token refresh in the real callers — on every pass and dialling
237
+ // nothing. The armed callback re-checks, because the URL can move between
238
+ // arming and firing.
239
+ if (options.getFreshUrl() !== wsUrl) return
240
+ if (!conn.retryOwner) conn.retryOwner = scheduleRetry
241
+ if (conn.retryOwner !== scheduleRetry) return
242
+
243
+ // One outage can raise more than one status: nats.ws reports
244
+ // `staleConnection` and then, once the transport is down, `disconnect`.
245
+ // Re-arming on the second would spend a backoff tier on the same outage —
246
+ // on the defaults, waiting 2000ms where the caller asked for 1000. The
247
+ // armed timer re-validates everything when it fires, so the first one
248
+ // scheduled for an outage is always the right one to keep.
249
+ if (conn.retryTimer) return
208
250
 
209
251
  const cfg = options.backoff ?? {}
210
252
  const fastRetries = cfg.fastRetries ?? 0
@@ -225,6 +267,16 @@ export function startConnectionLifecycle(options: ConnectionLifecycleOptions): C
225
267
  if (closed) return
226
268
  if (getSharedConnectionFor(wsUrl) !== conn) return
227
269
 
270
+ // Nothing to reconnect. Reached when the dial that was already in flight
271
+ // succeeded while this retry sat in its backoff — a handoff during a
272
+ // healthy first connect is enough to get here — and `onBeforeReconnect`
273
+ // is a token refresh in the real callers, so it must not run on a
274
+ // connection that came back on its own.
275
+ if (conn.client.isConnected()) {
276
+ retryAttempt = 0
277
+ return
278
+ }
279
+
228
280
  try {
229
281
  await options.onBeforeReconnect?.()
230
282
  } catch {
@@ -234,17 +286,33 @@ export function startConnectionLifecycle(options: ConnectionLifecycleOptions): C
234
286
  if (getSharedConnectionFor(wsUrl) !== conn) return
235
287
 
236
288
  const freshUrl = options.getFreshUrl()
237
- if (freshUrl !== wsUrl) return
289
+ if (freshUrl !== wsUrl) {
290
+ // This lifecycle has moved to a different URL, so it must not dial this
291
+ // connection again while holding the loop — that is the dead end the
292
+ // rest of this module exists to avoid. Give the loop up and push it to
293
+ // someone still on this URL.
294
+ if (conn.retryOwner === scheduleRetry) {
295
+ conn.retryOwner = null
296
+ // A status that landed while the refresh above was awaited can have
297
+ // armed a new timer under this ownership. It belongs to a lifecycle
298
+ // that is leaving, and a successor claiming while it is armed would
299
+ // return early believing the loop is covered — then this timer fires,
300
+ // finds someone else owns the loop, and bails without handing on.
301
+ if (conn.retryTimer) {
302
+ clearTimeout(conn.retryTimer)
303
+ conn.retryTimer = null
304
+ }
305
+ handOffRetry(conn)
306
+ }
307
+ return
308
+ }
238
309
 
239
310
  try {
240
- conn.connectPromise = null
241
- conn.connectPromise = conn.client.connect()
242
- await conn.connectPromise
311
+ await conn.client.connect()
243
312
  if (!closed && getSharedConnectionFor(wsUrl) === conn) {
244
313
  retryAttempt = 0
245
314
  }
246
315
  } catch {
247
- conn.connectPromise = null
248
316
  if (!closed && getSharedConnectionFor(wsUrl) === conn) {
249
317
  scheduleRetry()
250
318
  }
@@ -269,12 +337,20 @@ export function startConnectionLifecycle(options: ConnectionLifecycleOptions): C
269
337
  emitSynthetic('connected')
270
338
  }
271
339
 
340
+ // Dial through the client rather than caching a promise on the connection.
341
+ // A `conn.connectPromise` used to be assigned here with `||=` and then left
342
+ // in place after a SUCCESSFUL connect, so it doubled as "this connection has
343
+ // been dialled at some point": a consumer that acquired a still-cached
344
+ // connection whose socket had since died joined that long-settled promise
345
+ // instead of dialling. No connect, no failure, so nothing scheduled a retry,
346
+ // and no status event was coming to arm one either — the connection stayed
347
+ // dead for as long as anything held a reference to it. `NatsClient.connect()`
348
+ // already short-circuits on a live connection and joins its own in-flight
349
+ // dial, so the second copy of that bookkeeping bought nothing.
272
350
  void (async () => {
273
351
  try {
274
- conn.connectPromise ||= conn.client.connect()
275
- await conn.connectPromise
352
+ await conn.client.connect()
276
353
  } catch {
277
- conn.connectPromise = null
278
354
  if (closed) return
279
355
 
280
356
  emitSynthetic('disconnected')
@@ -286,13 +362,33 @@ export function startConnectionLifecycle(options: ConnectionLifecycleOptions): C
286
362
  stop() {
287
363
  closed = true
288
364
  unsubStatus()
365
+ conn.retrySchedulers.delete(scheduleRetry)
366
+
367
+ if (conn.retryOwner !== scheduleRetry) {
368
+ // Not ours to cancel. `retryTimer` lives on the CONNECTION, and
369
+ // clearing it unconditionally cancelled the owner's armed retry — so
370
+ // any consumer merely re-running its effect (an `enabled` toggle, a
371
+ // dialog closing, a route change) during a backoff killed reconnect
372
+ // for everyone. Nothing rescheduled it either, for the reason
373
+ // handOffRetry exists. The connection stayed down for good.
374
+ return
375
+ }
376
+
377
+ conn.retryOwner = null
378
+ const wasRetrying = conn.retryTimer !== null
289
379
  if (conn.retryTimer) {
290
380
  clearTimeout(conn.retryTimer)
291
381
  conn.retryTimer = null
292
382
  }
293
- if (conn.retryOwner === ownerToken) {
294
- conn.retryOwner = null
295
- }
383
+
384
+ // A healthy connection with nothing armed needs no successor: the next
385
+ // disconnect lets a survivor claim opportunistically. `wasRetrying` is
386
+ // read before the clearTimeout above, and the remaining case — the timer
387
+ // already fired and its callback is mid-dial — is caught by the
388
+ // connection not being connected. The successor backs off on its OWN
389
+ // attempt counter, which is zero unless it has driven the loop before.
390
+ if (!wasRetrying && conn.client.isConnected()) return
391
+ handOffRetry(conn)
296
392
  },
297
393
  }
298
394
  }