Skip to content

Commit a4ebed8

Browse files
leshniakclaude
andcommitted
feat: add visibilitychange probe for proactive IDB health check
Register a visibilitychange listener inside createStore() that runs a lightweight readonly probe when the tab returns to foreground. If the probe detects a dead IDB connection (connection lost, backing store error, or InvalidStateError), it drops the cached dbp so the next real operation opens a fresh connection instead of failing. This prevents the ReconnectApp write storm from hitting a dead IDB connection after Safari backgrounds a tab. Addresses Expensify/App#87864. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3fbb657 commit a4ebed8

2 files changed

Lines changed: 155 additions & 0 deletions

File tree

lib/storage/providers/IDBKeyValProvider/createStore.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ function getBudgetedHealErrorLabel(error: unknown): 'backing store' | 'connectio
3737
return isBackingStoreError(error) ? 'backing store' : 'connection lost';
3838
}
3939

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

133+
// Proactive IDB health check when tab returns to foreground.
134+
// Safari kills IDB connections for backgrounded tabs. By probing before
135+
// the ReconnectApp write storm hits, we drop the stale dbp early so the
136+
// first real operation opens a fresh connection instead of failing.
137+
if (typeof document !== 'undefined') {
138+
document.addEventListener('visibilitychange', () => {
139+
if (document.visibilityState !== 'visible' || !dbp) {
140+
return;
141+
}
142+
143+
const probePromise = dbp;
144+
145+
const dropCacheIfStale = (error: unknown) => {
146+
if (dbp !== probePromise || !isStaleConnectionError(error)) {
147+
return;
148+
}
149+
Logger.logInfo('IDB visibilitychange probe: connection lost, dropping cached connection', {dbName, storeName});
150+
dbp = undefined;
151+
};
152+
153+
probePromise.then((db) => {
154+
if (dbp !== probePromise) {
155+
return;
156+
}
157+
try {
158+
const tx = db.transaction(storeName, 'readonly');
159+
const probeStore = tx.objectStore(storeName);
160+
const req = probeStore.count();
161+
req.onerror = () => {
162+
dropCacheIfStale(req.error);
163+
};
164+
} catch (error) {
165+
dropCacheIfStale(error);
166+
}
167+
});
168+
});
169+
}
170+
128171
// Handles three recoverable error classes:
129172
// 1. InvalidStateError — connection closed between getDB() resolving and db.transaction().
130173
// Retry once with a fresh connection. No budget limit (transient, always worth one reopen).

tests/unit/storage/providers/createStoreTest.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,4 +553,116 @@ 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(logInfoSpy).toHaveBeenCalledWith('IDB visibilitychange probe: connection lost, dropping cached connection', 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+
expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('visibilitychange probe'), expect.anything());
633+
});
634+
635+
it('should drop dbp when probe throws InvalidStateError', async () => {
636+
const store = createStore(uniqueDBName(), STORE_NAME);
637+
638+
await store('readwrite', (s) => {
639+
s.put('value', 'key1');
640+
return IDB.promisifyRequest(s.transaction);
641+
});
642+
643+
simulateVisibilityChange('hidden');
644+
645+
const original = IDBDatabase.prototype.transaction;
646+
let callCount = 0;
647+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) {
648+
callCount++;
649+
if (callCount === 1) {
650+
throw new DOMException('The database connection is closing.', 'InvalidStateError');
651+
}
652+
return original.apply(this, args);
653+
});
654+
655+
simulateVisibilityChange('visible');
656+
657+
await new Promise((resolve) => {
658+
setTimeout(resolve, 0);
659+
});
660+
661+
jest.restoreAllMocks();
662+
663+
const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1')));
664+
expect(result).toBe('value');
665+
expect(logInfoSpy).toHaveBeenCalledWith('IDB visibilitychange probe: connection lost, dropping cached connection', expect.objectContaining({dbName: expect.any(String)}));
666+
});
667+
});
556668
});

0 commit comments

Comments
 (0)