@@ -56,6 +56,8 @@ jest.mock('@launchdarkly/js-client-sdk-common', () => ({
5656 } ) ,
5757} ) ) ;
5858
59+ import { LDClientImpl } from '@launchdarkly/js-client-sdk-common' ;
60+
5961import { createClient } from '../src' ;
6062
6163const DEFAULT_INITIAL_CONTEXT = { kind : 'user' as const , key : 'bob' } ;
@@ -285,6 +287,43 @@ it('setConnectionMode is a no-op when called with the current mode in FDv2', asy
285287 expect ( client . getConnectionMode ( ) ) . toBe ( 'streaming' ) ;
286288} ) ;
287289
290+ it ( 'duplicate-mode setConnectionMode awaits an in-flight transition to the same mode in FDv2' , async ( ) => {
291+ const client = createClient ( 'client-side-id' , DEFAULT_INITIAL_CONTEXT , {
292+ dataSystem : { } ,
293+ diagnosticOptOut : true ,
294+ sendEvents : false ,
295+ logger,
296+ localStoragePath : tmpRoot ,
297+ } ) ;
298+
299+ // Make the offline transition's flush() defer to a later microtask so the
300+ // offline -> streaming queue is still draining when the duplicate streaming
301+ // call is issued. This exposes the early-return race deterministically.
302+ const flushSpy = jest
303+ . spyOn ( LDClientImpl . prototype , 'flush' )
304+ . mockImplementation ( ( ) => Promise . resolve ( { result : true } ) ) ;
305+
306+ // Sequence: initial mode is 'streaming'. Go offline, then streaming, then
307+ // streaming again (the duplicate). The third call's _connectionMode is already
308+ // 'streaming' (set synchronously by the second call), so the idempotency guard
309+ // fires. Without the await fix, p3 resolves before the second call's queued
310+ // task has delegated 'streaming' to the data manager.
311+ const p1 = client . setConnectionMode ( 'offline' ) ;
312+ const p2 = client . setConnectionMode ( 'streaming' ) ;
313+ const p3 = client . setConnectionMode ( 'streaming' ) ;
314+
315+ // Awaiting only the duplicate call must guarantee the queued streaming
316+ // transition has completed (mockSetConnectionMode called with 'streaming').
317+ await p3 ;
318+ expect ( mockSetConnectionMode ) . toHaveBeenCalledWith ( 'streaming' ) ;
319+
320+ await Promise . all ( [ p1 , p2 ] ) ;
321+ expect ( client . getConnectionMode ( ) ) . toBe ( 'streaming' ) ;
322+ expect ( client . isOffline ( ) ) . toBe ( false ) ;
323+
324+ flushSpy . mockRestore ( ) ;
325+ } ) ;
326+
288327// ------ Post-close guard ------
289328
290329it ( 'setConnectionMode after close is a no-op in FDv2' , async ( ) => {
@@ -315,3 +354,51 @@ it('setConnectionMode with an invalid mode logs a warning and does not delegate
315354 expect ( mockSetConnectionMode ) . not . toHaveBeenCalled ( ) ;
316355 expect ( logger . warn ) . toHaveBeenCalledWith ( expect . stringContaining ( 'invalid-mode' ) ) ;
317356} ) ;
357+
358+ // ------ Mode rollback on failed transition ------
359+
360+ it ( 'restores connection mode when an FDv2 offline transition task throws' , async ( ) => {
361+ const client = createClient ( 'client-side-id' , DEFAULT_INITIAL_CONTEXT , {
362+ dataSystem : { } ,
363+ diagnosticOptOut : true ,
364+ sendEvents : false ,
365+ logger,
366+ localStoragePath : tmpRoot ,
367+ } ) ;
368+ // Force the queued offline transition task to throw by making flush() reject.
369+ const flushSpy = jest
370+ . spyOn ( LDClientImpl . prototype , 'flush' )
371+ . mockRejectedValueOnce ( new Error ( 'flush failed' ) ) ;
372+
373+ await expect ( client . setConnectionMode ( 'offline' ) ) . rejects . toThrow ( 'flush failed' ) ;
374+
375+ // The synchronously-set 'offline' must be rolled back to the prior mode so
376+ // isOffline() does not lie while the data manager is still streaming.
377+ expect ( client . getConnectionMode ( ) ) . toBe ( 'streaming' ) ;
378+ expect ( client . isOffline ( ) ) . toBe ( false ) ;
379+ // The data manager was never told to go offline.
380+ expect ( mockSetConnectionMode ) . not . toHaveBeenCalled ( ) ;
381+
382+ flushSpy . mockRestore ( ) ;
383+ } ) ;
384+
385+ it ( 'keeps the FDv2 transition queue alive for subsequent calls after a failed transition' , async ( ) => {
386+ const client = createClient ( 'client-side-id' , DEFAULT_INITIAL_CONTEXT , {
387+ dataSystem : { } ,
388+ diagnosticOptOut : true ,
389+ sendEvents : false ,
390+ logger,
391+ localStoragePath : tmpRoot ,
392+ } ) ;
393+ const flushSpy = jest
394+ . spyOn ( LDClientImpl . prototype , 'flush' )
395+ . mockRejectedValueOnce ( new Error ( 'flush failed' ) ) ;
396+
397+ await expect ( client . setConnectionMode ( 'offline' ) ) . rejects . toThrow ( 'flush failed' ) ;
398+ flushSpy . mockRestore ( ) ;
399+
400+ // A subsequent transition must still work; the queue was not poisoned.
401+ await client . setConnectionMode ( 'polling' ) ;
402+ expect ( client . getConnectionMode ( ) ) . toBe ( 'polling' ) ;
403+ expect ( mockSetConnectionMode ) . toHaveBeenCalledWith ( 'polling' ) ;
404+ } ) ;
0 commit comments