Skip to content
Closed
50 changes: 50 additions & 0 deletions lib/storage/providers/IDBKeyValProvider/createStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -127,6 +132,51 @@ 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);
}
});
});

// 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).
Expand Down
114 changes: 114 additions & 0 deletions tests/unit/storage/providers/createStoreTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,4 +553,118 @@ 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)}));
});
});
});
Loading