Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 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,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', () => {
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 Guard visibility listener outside document contexts

In non-window browser contexts that still support IndexedDB, such as Web Workers/service workers, document is undefined while indexedDB is available (MDN documents WorkerGlobalScope.indexedDB: https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope/indexedDB). Because createStore() now touches document during initialization, Onyx.init() throws before the store can be created in those contexts, whereas the previous implementation could use IndexedDB there. Wrap this listener setup in a typeof document !== 'undefined' check so worker-based consumers keep working and only window tabs install the foreground probe.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

its only run in web and we had this change before and we reverted it

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