diff --git a/lib/storage/providers/IDBKeyValProvider/createStore.ts b/lib/storage/providers/IDBKeyValProvider/createStore.ts index 0d3f07913..4d0304ed4 100644 --- a/lib/storage/providers/IDBKeyValProvider/createStore.ts +++ b/lib/storage/providers/IDBKeyValProvider/createStore.ts @@ -39,6 +39,11 @@ function getBudgetedHealErrorLabel(error: unknown): string { return 'unknown'; } +/** Union of all error types indicating a stale/dead IDB connection. Used by the visibilitychange probe. */ +function isStaleConnectionError(error: unknown): boolean { + return isInvalidStateError(error) || isBackingStoreError(error) || isConnectionLostError(error); +} + // This is a copy of the createStore function from idb-keyval, we need a custom implementation // because we need to create the database manually in order to ensure that the store exists before we use it. // If the store does not exist, idb-keyval will throw an error @@ -127,6 +132,56 @@ function createStore(dbName: string, storeName: string): UseStore { return result; } + // Proactive IDB health check when tab returns to foreground. + // Safari kills IDB connections for backgrounded tabs. By probing as soon as + // the tab becomes visible, we drop the stale dbp early so the first real + // operation opens a fresh connection instead of failing. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible' || !dbp) { + return; + } + + Logger.logInfo('IDB visibilitychange probe: tab became visible, checking connection health', {dbName, storeName}); + + const probePromise = dbp; + + const dropCacheIfStale = (error: unknown) => { + if (dbp !== probePromise || !isStaleConnectionError(error)) { + return; + } + Logger.logAlert('IDB visibilitychange probe: stale connection detected, dropping cached connection', { + dbName, + storeName, + errorMessage: error instanceof Error ? error.message : String(error), + }); + dbp = undefined; + }; + + probePromise + .then((db) => { + if (dbp !== probePromise) { + return; + } + try { + const tx = db.transaction(storeName, 'readonly'); + const probeStore = tx.objectStore(storeName); + const req = probeStore.count(); + req.onsuccess = () => { + Logger.logInfo('IDB visibilitychange probe: connection is healthy', {dbName, storeName}); + }; + req.onerror = () => { + dropCacheIfStale(req.error); + }; + } catch (error) { + dropCacheIfStale(error); + } + }) + .catch(() => { + // The cached open promise rejected; cacheOpenPromise already cleared dbp on its own + // branch. Swallow here so the probe's separate branch doesn't surface an unhandled rejection. + }); + }); + // Handles three recoverable error classes: // 1. InvalidStateError — connection closed between getDB() resolving and db.transaction(). // Retry once with a fresh connection. No budget limit (transient, always worth one reopen). diff --git a/tests/unit/storage/providers/createStoreTest.ts b/tests/unit/storage/providers/createStoreTest.ts index bb6530c3b..437b23c00 100644 --- a/tests/unit/storage/providers/createStoreTest.ts +++ b/tests/unit/storage/providers/createStoreTest.ts @@ -553,4 +553,163 @@ describe('createStore', () => { expect(logAlertSpy).not.toHaveBeenCalledWith(expect.stringContaining('dropping cached connection and reopening'), expect.anything()); }); }); + + describe('visibilitychange probe', () => { + function simulateVisibilityChange(state: string) { + Object.defineProperty(document, 'visibilityState', {value: state, writable: true, configurable: true}); + document.dispatchEvent(new Event('visibilitychange')); + } + + afterEach(() => { + Object.defineProperty(document, 'visibilityState', {value: 'visible', writable: true, configurable: true}); + }); + + it('should drop stale dbp when probe detects connection lost on foreground', async () => { + const store = createStore(uniqueDBName(), STORE_NAME); + + await store('readwrite', (s) => { + s.put('value', 'key1'); + return IDB.promisifyRequest(s.transaction); + }); + + simulateVisibilityChange('hidden'); + + const original = IDBDatabase.prototype.transaction; + let probeIntercepted = false; + jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + if (!probeIntercepted) { + probeIntercepted = true; + throw new DOMException('Connection to Indexed Database server lost. Refresh the page to try again', 'UnknownError'); + } + return original.apply(this, args); + }); + + simulateVisibilityChange('visible'); + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + jest.restoreAllMocks(); + + const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); + expect(result).toBe('value'); + expect(logAlertSpy).toHaveBeenCalledWith(expect.stringContaining('IDB visibilitychange probe: stale connection detected'), expect.objectContaining({dbName: expect.any(String)})); + }); + + it('should not probe when no connection exists yet', async () => { + const dbName = uniqueDBName(); + createStore(dbName, STORE_NAME); + + simulateVisibilityChange('hidden'); + simulateVisibilityChange('visible'); + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + // No probe log for this specific store (dbp was never set) + expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('visibilitychange probe'), expect.objectContaining({dbName})); + }); + + it('should keep connection when probe succeeds', async () => { + const store = createStore(uniqueDBName(), STORE_NAME); + + await store('readwrite', (s) => { + s.put('value', 'key1'); + return IDB.promisifyRequest(s.transaction); + }); + + simulateVisibilityChange('hidden'); + simulateVisibilityChange('visible'); + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); + expect(result).toBe('value'); + // Probe ran but found healthy connection — no stale connection alert + expect(logAlertSpy).not.toHaveBeenCalledWith(expect.stringContaining('stale connection detected'), expect.anything()); + expect(logInfoSpy).toHaveBeenCalledWith(expect.stringContaining('connection is healthy'), expect.anything()); + }); + + it('should drop dbp when probe throws InvalidStateError', async () => { + const store = createStore(uniqueDBName(), STORE_NAME); + + await store('readwrite', (s) => { + s.put('value', 'key1'); + return IDB.promisifyRequest(s.transaction); + }); + + simulateVisibilityChange('hidden'); + + const original = IDBDatabase.prototype.transaction; + let callCount = 0; + jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + callCount++; + if (callCount === 1) { + throw new DOMException('The database connection is closing.', 'InvalidStateError'); + } + return original.apply(this, args); + }); + + simulateVisibilityChange('visible'); + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + jest.restoreAllMocks(); + + const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); + expect(result).toBe('value'); + expect(logAlertSpy).toHaveBeenCalledWith(expect.stringContaining('IDB visibilitychange probe: stale connection detected'), expect.objectContaining({dbName: expect.any(String)})); + }); + + it('should not surface an unhandled rejection when the probe runs while the open promise rejects', async () => { + const store = createStore(uniqueDBName(), STORE_NAME); + + // Keep the initial open pending until we reject it manually, so the probe attaches to a pending dbp. + let rejectOpen: () => void = () => { + /* assigned inside the indexedDB.open mock below */ + }; + jest.spyOn(indexedDB, 'open').mockImplementation(() => { + const req = {} as IDBOpenDBRequest; + rejectOpen = () => { + Object.defineProperty(req, 'error', {value: new DOMException('probe open failed', 'AbortError'), configurable: true}); + req.onerror?.(new Event('error') as Event & {target: IDBOpenDBRequest}); + }; + return req; + }); + + const unhandled = jest.fn(); + process.on('unhandledRejection', unhandled); + + try { + // Start an op so dbp becomes a pending open promise; keep its rejection handled. + const opAssertion = expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow('probe open failed'); + + // Tab becomes visible while the open is still pending — probe attaches to the pending dbp. + simulateVisibilityChange('hidden'); + simulateVisibilityChange('visible'); + + // Now the open rejects — both the op chain and the probe chain see it. + rejectOpen(); + + await opAssertion; + // Give the probe's separate branch a couple of ticks to (not) surface an unhandled rejection. + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + } + }); + }); });