From 0e67599c52d7680d406e3dc38258888d70238ceb Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 3 Jun 2026 12:23:34 +0200 Subject: [PATCH 1/4] add promisifyWriteTransaction to unify how merge and set actions treat transactions and related AbortErrors --- .../providers/IDBKeyValProvider/index.ts | 26 +++++++++-- .../providers/IDBKeyvalProviderTest.ts | 46 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/lib/storage/providers/IDBKeyValProvider/index.ts b/lib/storage/providers/IDBKeyValProvider/index.ts index 515a3c5c8..c8d063d51 100644 --- a/lib/storage/providers/IDBKeyValProvider/index.ts +++ b/lib/storage/providers/IDBKeyValProvider/index.ts @@ -9,6 +9,20 @@ import type {StorageKeyValuePair} from '../types'; const DB_NAME = 'OnyxDB'; const STORE_NAME = 'keyvaluepairs'; +/** + * Awaits an IndexedDB write transaction. idb-keyval's promisifyRequest rejects with + * `transaction.error`, which is `null` for an abort not caused by its own request + * (connection close / versionchange / a sibling transaction aborting). Normalize that + * `null` into a tagged AbortError so storage writes never reject with the unclassifiable + * "Error: null" — it otherwise slips past createStore's heal guards (they require an + * Error/DOMException) and renders as `Error: null` in OnyxUtils.retryOperation. + */ +function promisifyWriteTransaction(transaction: IDBTransaction): Promise { + return IDB.promisifyRequest(transaction).catch((error) => { + throw error ?? new DOMException('IDB write transaction aborted without an error', 'AbortError'); + }); +} + const provider: StorageProvider = { // We don't want to initialize the store while the JS bundle loads as idb-keyval will try to use global.indexedDB // which might not be available in certain environments that load the bundle (e.g. electron main process). @@ -38,7 +52,13 @@ const provider: StorageProvider = { return provider.removeItem(key); } - return IDB.set(key, value, provider.store); + // Drive the write through the manual store transaction so promisifyWriteTransaction can + // normalize a null abort error — idb-keyval's IDB.set() awaits the raw transaction and + // would propagate the unclassifiable "Error: null". + return provider.store('readwrite', (store) => { + store.put(value, key); + return promisifyWriteTransaction(store.transaction); + }); }, multiGet(keysParam) { if (!provider.store) { @@ -71,7 +91,7 @@ const provider: StorageProvider = { } } - return IDB.promisifyRequest(store.transaction); + return promisifyWriteTransaction(store.transaction); }); }); }, @@ -93,7 +113,7 @@ const provider: StorageProvider = { } } - return IDB.promisifyRequest(store.transaction); + return promisifyWriteTransaction(store.transaction); }); }, clear() { diff --git a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts index ca34611f9..fe7852dea 100644 --- a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts +++ b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts @@ -174,6 +174,52 @@ describe('IDBKeyValProvider', () => { }); }); + describe('write-error normalization (aborted transactions)', () => { + // A write transaction aborted by something other than its own request (connection close, + // versionchange, a sibling transaction) leaves `transaction.error === null`. idb-keyval + // rejects with that null, which is unclassifiable: it slips past createStore's heal guards + // (they require an Error/DOMException) and renders as the production log line + // "[Onyx] Failed to save to storage. Error: null". Every write path must instead reject + // with a real Error so the failure can be classified and retried sanely. + function abortTransactionOnPut() { + const originalPut = IDBObjectStore.prototype.put; + jest.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function put(this: IDBObjectStore, ...args: Parameters) { + const request = originalPut.apply(this, args); + this.transaction.abort(); + return request; + }); + } + + function expectAbortError(error: unknown) { + expect(error).not.toBeNull(); + expect(error).toBeInstanceOf(DOMException); + expect((error as DOMException).name).toBe('AbortError'); + expect((error as DOMException).message.length).toBeGreaterThan(0); + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('setItem rejects with a tagged AbortError, never null', async () => { + abortTransactionOnPut(); + const error = await IDBKeyValProvider.setItem(ONYXKEYS.TEST_KEY, 'value').catch((e: unknown) => e); + expectAbortError(error); + }); + + it('multiSet rejects with a tagged AbortError, never null', async () => { + abortTransactionOnPut(); + const error = await IDBKeyValProvider.multiSet([[ONYXKEYS.TEST_KEY, 'value']]).catch((e: unknown) => e); + expectAbortError(error); + }); + + it('multiMerge rejects with a tagged AbortError, never null', async () => { + abortTransactionOnPut(); + const error = await IDBKeyValProvider.multiMerge([[ONYXKEYS.TEST_KEY, 'value']]).catch((e: unknown) => e); + expectAbortError(error); + }); + }); + describe('mergeItem', () => { it('should merge all the supported kinds of data correctly', async () => { await IDB.set(ONYXKEYS.TEST_KEY, 'value', IDBKeyValProvider.store); From 8d3c80e2aaa040d980d27b1c0b3477af6151f667 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 8 Jun 2026 11:41:09 +0200 Subject: [PATCH 2/4] fix test names --- tests/unit/storage/providers/IDBKeyvalProviderTest.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts index fe7852dea..e5b8d8bf5 100644 --- a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts +++ b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts @@ -201,19 +201,19 @@ describe('IDBKeyValProvider', () => { jest.restoreAllMocks(); }); - it('setItem rejects with a tagged AbortError, never null', async () => { + it('should reject setItem with a tagged AbortError, never null', async () => { abortTransactionOnPut(); const error = await IDBKeyValProvider.setItem(ONYXKEYS.TEST_KEY, 'value').catch((e: unknown) => e); expectAbortError(error); }); - it('multiSet rejects with a tagged AbortError, never null', async () => { + it('should reject multiSet with a tagged AbortError, never null', async () => { abortTransactionOnPut(); const error = await IDBKeyValProvider.multiSet([[ONYXKEYS.TEST_KEY, 'value']]).catch((e: unknown) => e); expectAbortError(error); }); - it('multiMerge rejects with a tagged AbortError, never null', async () => { + it('should reject multiMerge with a tagged AbortError, never null', async () => { abortTransactionOnPut(); const error = await IDBKeyValProvider.multiMerge([[ONYXKEYS.TEST_KEY, 'value']]).catch((e: unknown) => e); expectAbortError(error); From 4aa0d40811bba19b880bf10be4e40b7655e6d678 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 8 Jun 2026 11:48:36 +0200 Subject: [PATCH 3/4] add solution to removeItem --- .../providers/IDBKeyValProvider/index.ts | 12 +++++-- .../providers/IDBKeyvalProviderTest.ts | 35 ++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/lib/storage/providers/IDBKeyValProvider/index.ts b/lib/storage/providers/IDBKeyValProvider/index.ts index c8d063d51..230edd249 100644 --- a/lib/storage/providers/IDBKeyValProvider/index.ts +++ b/lib/storage/providers/IDBKeyValProvider/index.ts @@ -153,14 +153,22 @@ const provider: StorageProvider = { throw new Error('Store not initialized!'); } - return IDB.del(key, provider.store); + return provider.store('readwrite', (store) => { + store.delete(key); + return promisifyWriteTransaction(store.transaction); + }); }, removeItems(keysParam) { if (!provider.store) { throw new Error('Store not initialized!'); } - return IDB.delMany(keysParam, provider.store); + return provider.store('readwrite', (store) => { + for (const key of keysParam) { + store.delete(key); + } + return promisifyWriteTransaction(store.transaction); + }); }, getDatabaseSize() { if (!provider.store) { diff --git a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts index e5b8d8bf5..c9c2be33d 100644 --- a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts +++ b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts @@ -176,11 +176,9 @@ describe('IDBKeyValProvider', () => { describe('write-error normalization (aborted transactions)', () => { // A write transaction aborted by something other than its own request (connection close, - // versionchange, a sibling transaction) leaves `transaction.error === null`. idb-keyval - // rejects with that null, which is unclassifiable: it slips past createStore's heal guards - // (they require an Error/DOMException) and renders as the production log line - // "[Onyx] Failed to save to storage. Error: null". Every write path must instead reject - // with a real Error so the failure can be classified and retried sanely. + // versionchange, a sibling transaction) leaves `transaction.error === null`, which idb-keyval + // rejects with as-is. Every write path must instead reject with a real Error so the failure + // can be classified and retried. function abortTransactionOnPut() { const originalPut = IDBObjectStore.prototype.put; jest.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function put(this: IDBObjectStore, ...args: Parameters) { @@ -190,6 +188,15 @@ describe('IDBKeyValProvider', () => { }); } + function abortTransactionOnDelete() { + const originalDelete = IDBObjectStore.prototype.delete; + jest.spyOn(IDBObjectStore.prototype, 'delete').mockImplementation(function del(this: IDBObjectStore, ...args: Parameters) { + const request = originalDelete.apply(this, args); + this.transaction.abort(); + return request; + }); + } + function expectAbortError(error: unknown) { expect(error).not.toBeNull(); expect(error).toBeInstanceOf(DOMException); @@ -218,6 +225,24 @@ describe('IDBKeyValProvider', () => { const error = await IDBKeyValProvider.multiMerge([[ONYXKEYS.TEST_KEY, 'value']]).catch((e: unknown) => e); expectAbortError(error); }); + + it('should reject setItem(null) with a tagged AbortError, never null', async () => { + abortTransactionOnDelete(); + const error = await IDBKeyValProvider.setItem(ONYXKEYS.TEST_KEY, null).catch((e: unknown) => e); + expectAbortError(error); + }); + + it('should reject removeItem with a tagged AbortError, never null', async () => { + abortTransactionOnDelete(); + const error = await IDBKeyValProvider.removeItem(ONYXKEYS.TEST_KEY).catch((e: unknown) => e); + expectAbortError(error); + }); + + it('should reject removeItems with a tagged AbortError, never null', async () => { + abortTransactionOnDelete(); + const error = await IDBKeyValProvider.removeItems([ONYXKEYS.TEST_KEY]).catch((e: unknown) => e); + expectAbortError(error); + }); }); describe('mergeItem', () => { From d7570164e5374567d66431e3765ee24b3f8708b6 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 8 Jun 2026 11:54:41 +0200 Subject: [PATCH 4/4] update comment --- lib/storage/providers/IDBKeyValProvider/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/storage/providers/IDBKeyValProvider/index.ts b/lib/storage/providers/IDBKeyValProvider/index.ts index 230edd249..68768d1b3 100644 --- a/lib/storage/providers/IDBKeyValProvider/index.ts +++ b/lib/storage/providers/IDBKeyValProvider/index.ts @@ -13,9 +13,7 @@ const STORE_NAME = 'keyvaluepairs'; * Awaits an IndexedDB write transaction. idb-keyval's promisifyRequest rejects with * `transaction.error`, which is `null` for an abort not caused by its own request * (connection close / versionchange / a sibling transaction aborting). Normalize that - * `null` into a tagged AbortError so storage writes never reject with the unclassifiable - * "Error: null" — it otherwise slips past createStore's heal guards (they require an - * Error/DOMException) and renders as `Error: null` in OnyxUtils.retryOperation. + * `null` into a tagged AbortError. */ function promisifyWriteTransaction(transaction: IDBTransaction): Promise { return IDB.promisifyRequest(transaction).catch((error) => {