Skip to content

Commit 595d944

Browse files
authored
Merge pull request Expensify#798 from callstack-internal/fix/idb-visibilitychange-probe-v2
feat: add visibilitychange probe for proactive IDB health check
2 parents d91ec72 + 7ac9c50 commit 595d944

2 files changed

Lines changed: 214 additions & 0 deletions

File tree

lib/storage/providers/IDBKeyValProvider/createStore.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ function getBudgetedHealErrorLabel(error: unknown): string {
3939
return 'unknown';
4040
}
4141

42+
/** Union of all error types indicating a stale/dead IDB connection. Used by the visibilitychange probe. */
43+
function isStaleConnectionError(error: unknown): boolean {
44+
return isInvalidStateError(error) || isBackingStoreError(error) || isConnectionLostError(error);
45+
}
46+
4247
// This is a copy of the createStore function from idb-keyval, we need a custom implementation
4348
// because we need to create the database manually in order to ensure that the store exists before we use it.
4449
// If the store does not exist, idb-keyval will throw an error
@@ -127,6 +132,56 @@ function createStore(dbName: string, storeName: string): UseStore {
127132
return result;
128133
}
129134

135+
// Proactive IDB health check when tab returns to foreground.
136+
// Safari kills IDB connections for backgrounded tabs. By probing as soon as
137+
// the tab becomes visible, we drop the stale dbp early so the first real
138+
// operation opens a fresh connection instead of failing.
139+
document.addEventListener('visibilitychange', () => {
140+
if (document.visibilityState !== 'visible' || !dbp) {
141+
return;
142+
}
143+
144+
Logger.logInfo('IDB visibilitychange probe: tab became visible, checking connection health', {dbName, storeName});
145+
146+
const probePromise = dbp;
147+
148+
const dropCacheIfStale = (error: unknown) => {
149+
if (dbp !== probePromise || !isStaleConnectionError(error)) {
150+
return;
151+
}
152+
Logger.logAlert('IDB visibilitychange probe: stale connection detected, dropping cached connection', {
153+
dbName,
154+
storeName,
155+
errorMessage: error instanceof Error ? error.message : String(error),
156+
});
157+
dbp = undefined;
158+
};
159+
160+
probePromise
161+
.then((db) => {
162+
if (dbp !== probePromise) {
163+
return;
164+
}
165+
try {
166+
const tx = db.transaction(storeName, 'readonly');
167+
const probeStore = tx.objectStore(storeName);
168+
const req = probeStore.count();
169+
req.onsuccess = () => {
170+
Logger.logInfo('IDB visibilitychange probe: connection is healthy', {dbName, storeName});
171+
};
172+
req.onerror = () => {
173+
dropCacheIfStale(req.error);
174+
};
175+
} catch (error) {
176+
dropCacheIfStale(error);
177+
}
178+
})
179+
.catch(() => {
180+
// The cached open promise rejected; cacheOpenPromise already cleared dbp on its own
181+
// branch. Swallow here so the probe's separate branch doesn't surface an unhandled rejection.
182+
});
183+
});
184+
130185
// Handles three recoverable error classes:
131186
// 1. InvalidStateError — connection closed between getDB() resolving and db.transaction().
132187
// Retry once with a fresh connection. No budget limit (transient, always worth one reopen).

tests/unit/storage/providers/createStoreTest.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)