@@ -553,4 +553,163 @@ describe('createStore', () => {
553553 expect ( logAlertSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'dropping cached connection and reopening' ) , expect . anything ( ) ) ;
554554 } ) ;
555555 } ) ;
556+
557+ describe ( 'visibilitychange probe' , ( ) => {
558+ function simulateVisibilityChange ( state : string ) {
559+ Object . defineProperty ( document , 'visibilityState' , { value : state , writable : true , configurable : true } ) ;
560+ document . dispatchEvent ( new Event ( 'visibilitychange' ) ) ;
561+ }
562+
563+ afterEach ( ( ) => {
564+ Object . defineProperty ( document , 'visibilityState' , { value : 'visible' , writable : true , configurable : true } ) ;
565+ } ) ;
566+
567+ it ( 'should drop stale dbp when probe detects connection lost on foreground' , async ( ) => {
568+ const store = createStore ( uniqueDBName ( ) , STORE_NAME ) ;
569+
570+ await store ( 'readwrite' , ( s ) => {
571+ s . put ( 'value' , 'key1' ) ;
572+ return IDB . promisifyRequest ( s . transaction ) ;
573+ } ) ;
574+
575+ simulateVisibilityChange ( 'hidden' ) ;
576+
577+ const original = IDBDatabase . prototype . transaction ;
578+ let probeIntercepted = false ;
579+ jest . spyOn ( IDBDatabase . prototype , 'transaction' ) . mockImplementation ( function ( this : IDBDatabase , ...args ) {
580+ if ( ! probeIntercepted ) {
581+ probeIntercepted = true ;
582+ throw new DOMException ( 'Connection to Indexed Database server lost. Refresh the page to try again' , 'UnknownError' ) ;
583+ }
584+ return original . apply ( this , args ) ;
585+ } ) ;
586+
587+ simulateVisibilityChange ( 'visible' ) ;
588+
589+ await new Promise ( ( resolve ) => {
590+ setTimeout ( resolve , 0 ) ;
591+ } ) ;
592+
593+ jest . restoreAllMocks ( ) ;
594+
595+ const result = await store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ;
596+ expect ( result ) . toBe ( 'value' ) ;
597+ expect ( logAlertSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'IDB visibilitychange probe: stale connection detected' ) , expect . objectContaining ( { dbName : expect . any ( String ) } ) ) ;
598+ } ) ;
599+
600+ it ( 'should not probe when no connection exists yet' , async ( ) => {
601+ const dbName = uniqueDBName ( ) ;
602+ createStore ( dbName , STORE_NAME ) ;
603+
604+ simulateVisibilityChange ( 'hidden' ) ;
605+ simulateVisibilityChange ( 'visible' ) ;
606+
607+ await new Promise ( ( resolve ) => {
608+ setTimeout ( resolve , 0 ) ;
609+ } ) ;
610+
611+ // No probe log for this specific store (dbp was never set)
612+ expect ( logInfoSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'visibilitychange probe' ) , expect . objectContaining ( { dbName} ) ) ;
613+ } ) ;
614+
615+ it ( 'should keep connection when probe succeeds' , async ( ) => {
616+ const store = createStore ( uniqueDBName ( ) , STORE_NAME ) ;
617+
618+ await store ( 'readwrite' , ( s ) => {
619+ s . put ( 'value' , 'key1' ) ;
620+ return IDB . promisifyRequest ( s . transaction ) ;
621+ } ) ;
622+
623+ simulateVisibilityChange ( 'hidden' ) ;
624+ simulateVisibilityChange ( 'visible' ) ;
625+
626+ await new Promise ( ( resolve ) => {
627+ setTimeout ( resolve , 0 ) ;
628+ } ) ;
629+
630+ const result = await store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ;
631+ expect ( result ) . toBe ( 'value' ) ;
632+ // Probe ran but found healthy connection — no stale connection alert
633+ expect ( logAlertSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'stale connection detected' ) , expect . anything ( ) ) ;
634+ expect ( logInfoSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'connection is healthy' ) , expect . anything ( ) ) ;
635+ } ) ;
636+
637+ it ( 'should drop dbp when probe throws InvalidStateError' , async ( ) => {
638+ const store = createStore ( uniqueDBName ( ) , STORE_NAME ) ;
639+
640+ await store ( 'readwrite' , ( s ) => {
641+ s . put ( 'value' , 'key1' ) ;
642+ return IDB . promisifyRequest ( s . transaction ) ;
643+ } ) ;
644+
645+ simulateVisibilityChange ( 'hidden' ) ;
646+
647+ const original = IDBDatabase . prototype . transaction ;
648+ let callCount = 0 ;
649+ jest . spyOn ( IDBDatabase . prototype , 'transaction' ) . mockImplementation ( function ( this : IDBDatabase , ...args ) {
650+ callCount ++ ;
651+ if ( callCount === 1 ) {
652+ throw new DOMException ( 'The database connection is closing.' , 'InvalidStateError' ) ;
653+ }
654+ return original . apply ( this , args ) ;
655+ } ) ;
656+
657+ simulateVisibilityChange ( 'visible' ) ;
658+
659+ await new Promise ( ( resolve ) => {
660+ setTimeout ( resolve , 0 ) ;
661+ } ) ;
662+
663+ jest . restoreAllMocks ( ) ;
664+
665+ const result = await store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ;
666+ expect ( result ) . toBe ( 'value' ) ;
667+ expect ( logAlertSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'IDB visibilitychange probe: stale connection detected' ) , expect . objectContaining ( { dbName : expect . any ( String ) } ) ) ;
668+ } ) ;
669+
670+ it ( 'should not surface an unhandled rejection when the probe runs while the open promise rejects' , async ( ) => {
671+ const store = createStore ( uniqueDBName ( ) , STORE_NAME ) ;
672+
673+ // Keep the initial open pending until we reject it manually, so the probe attaches to a pending dbp.
674+ let rejectOpen : ( ) => void = ( ) => {
675+ /* assigned inside the indexedDB.open mock below */
676+ } ;
677+ jest . spyOn ( indexedDB , 'open' ) . mockImplementation ( ( ) => {
678+ const req = { } as IDBOpenDBRequest ;
679+ rejectOpen = ( ) => {
680+ Object . defineProperty ( req , 'error' , { value : new DOMException ( 'probe open failed' , 'AbortError' ) , configurable : true } ) ;
681+ req . onerror ?.( new Event ( 'error' ) as Event & { target : IDBOpenDBRequest } ) ;
682+ } ;
683+ return req ;
684+ } ) ;
685+
686+ const unhandled = jest . fn ( ) ;
687+ process . on ( 'unhandledRejection' , unhandled ) ;
688+
689+ try {
690+ // Start an op so dbp becomes a pending open promise; keep its rejection handled.
691+ const opAssertion = expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ) . rejects . toThrow ( 'probe open failed' ) ;
692+
693+ // Tab becomes visible while the open is still pending — probe attaches to the pending dbp.
694+ simulateVisibilityChange ( 'hidden' ) ;
695+ simulateVisibilityChange ( 'visible' ) ;
696+
697+ // Now the open rejects — both the op chain and the probe chain see it.
698+ rejectOpen ( ) ;
699+
700+ await opAssertion ;
701+ // Give the probe's separate branch a couple of ticks to (not) surface an unhandled rejection.
702+ await new Promise ( ( resolve ) => {
703+ setTimeout ( resolve , 0 ) ;
704+ } ) ;
705+ await new Promise ( ( resolve ) => {
706+ setTimeout ( resolve , 0 ) ;
707+ } ) ;
708+
709+ expect ( unhandled ) . not . toHaveBeenCalled ( ) ;
710+ } finally {
711+ process . off ( 'unhandledRejection' , unhandled ) ;
712+ }
713+ } ) ;
714+ } ) ;
556715} ) ;
0 commit comments