Skip to content
Closed
52 changes: 52 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,53 @@ 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 before
// the ReconnectApp write storm hits, we drop the stale dbp early so the
// first real operation opens a fresh connection instead of failing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove mentions of ReconnectApp

if (typeof document !== 'undefined') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to have this condition? As IDB is used in web platforms only this is supposed to be always defined, right?

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) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle probe promise rejections

When the tab becomes visible while dbp is still a pending indexedDB.open() and that open later rejects (for example with the same backing-store or connection-lost errors handled below), this separate probePromise.then(...) chain rejects without any rejection handler. The original store operation may catch its own chain, but the promise returned by this then is ignored, so browsers can emit an unhandled rejection during foregrounding; add a rejection handler/catch for the probe path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@elirangoshen have you checked this comment? Is it still relevant?

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