Skip to content

Commit 0e67599

Browse files
committed
add promisifyWriteTransaction to unify how merge and set actions treat transactions and related AbortErrors
1 parent 14be5b8 commit 0e67599

2 files changed

Lines changed: 69 additions & 3 deletions

File tree

lib/storage/providers/IDBKeyValProvider/index.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ import type {StorageKeyValuePair} from '../types';
99
const DB_NAME = 'OnyxDB';
1010
const STORE_NAME = 'keyvaluepairs';
1111

12+
/**
13+
* Awaits an IndexedDB write transaction. idb-keyval's promisifyRequest rejects with
14+
* `transaction.error`, which is `null` for an abort not caused by its own request
15+
* (connection close / versionchange / a sibling transaction aborting). Normalize that
16+
* `null` into a tagged AbortError so storage writes never reject with the unclassifiable
17+
* "Error: null" — it otherwise slips past createStore's heal guards (they require an
18+
* Error/DOMException) and renders as `Error: null` in OnyxUtils.retryOperation.
19+
*/
20+
function promisifyWriteTransaction(transaction: IDBTransaction): Promise<void> {
21+
return IDB.promisifyRequest(transaction).catch((error) => {
22+
throw error ?? new DOMException('IDB write transaction aborted without an error', 'AbortError');
23+
});
24+
}
25+
1226
const provider: StorageProvider<UseStore | undefined> = {
1327
// We don't want to initialize the store while the JS bundle loads as idb-keyval will try to use global.indexedDB
1428
// which might not be available in certain environments that load the bundle (e.g. electron main process).
@@ -38,7 +52,13 @@ const provider: StorageProvider<UseStore | undefined> = {
3852
return provider.removeItem(key);
3953
}
4054

41-
return IDB.set(key, value, provider.store);
55+
// Drive the write through the manual store transaction so promisifyWriteTransaction can
56+
// normalize a null abort error — idb-keyval's IDB.set() awaits the raw transaction and
57+
// would propagate the unclassifiable "Error: null".
58+
return provider.store('readwrite', (store) => {
59+
store.put(value, key);
60+
return promisifyWriteTransaction(store.transaction);
61+
});
4262
},
4363
multiGet(keysParam) {
4464
if (!provider.store) {
@@ -71,7 +91,7 @@ const provider: StorageProvider<UseStore | undefined> = {
7191
}
7292
}
7393

74-
return IDB.promisifyRequest(store.transaction);
94+
return promisifyWriteTransaction(store.transaction);
7595
});
7696
});
7797
},
@@ -93,7 +113,7 @@ const provider: StorageProvider<UseStore | undefined> = {
93113
}
94114
}
95115

96-
return IDB.promisifyRequest(store.transaction);
116+
return promisifyWriteTransaction(store.transaction);
97117
});
98118
},
99119
clear() {

tests/unit/storage/providers/IDBKeyvalProviderTest.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,52 @@ describe('IDBKeyValProvider', () => {
174174
});
175175
});
176176

177+
describe('write-error normalization (aborted transactions)', () => {
178+
// A write transaction aborted by something other than its own request (connection close,
179+
// versionchange, a sibling transaction) leaves `transaction.error === null`. idb-keyval
180+
// rejects with that null, which is unclassifiable: it slips past createStore's heal guards
181+
// (they require an Error/DOMException) and renders as the production log line
182+
// "[Onyx] Failed to save to storage. Error: null". Every write path must instead reject
183+
// with a real Error so the failure can be classified and retried sanely.
184+
function abortTransactionOnPut() {
185+
const originalPut = IDBObjectStore.prototype.put;
186+
jest.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function put(this: IDBObjectStore, ...args: Parameters<IDBObjectStore['put']>) {
187+
const request = originalPut.apply(this, args);
188+
this.transaction.abort();
189+
return request;
190+
});
191+
}
192+
193+
function expectAbortError(error: unknown) {
194+
expect(error).not.toBeNull();
195+
expect(error).toBeInstanceOf(DOMException);
196+
expect((error as DOMException).name).toBe('AbortError');
197+
expect((error as DOMException).message.length).toBeGreaterThan(0);
198+
}
199+
200+
afterEach(() => {
201+
jest.restoreAllMocks();
202+
});
203+
204+
it('setItem rejects with a tagged AbortError, never null', async () => {
205+
abortTransactionOnPut();
206+
const error = await IDBKeyValProvider.setItem(ONYXKEYS.TEST_KEY, 'value').catch((e: unknown) => e);
207+
expectAbortError(error);
208+
});
209+
210+
it('multiSet rejects with a tagged AbortError, never null', async () => {
211+
abortTransactionOnPut();
212+
const error = await IDBKeyValProvider.multiSet([[ONYXKEYS.TEST_KEY, 'value']]).catch((e: unknown) => e);
213+
expectAbortError(error);
214+
});
215+
216+
it('multiMerge rejects with a tagged AbortError, never null', async () => {
217+
abortTransactionOnPut();
218+
const error = await IDBKeyValProvider.multiMerge([[ONYXKEYS.TEST_KEY, 'value']]).catch((e: unknown) => e);
219+
expectAbortError(error);
220+
});
221+
});
222+
177223
describe('mergeItem', () => {
178224
it('should merge all the supported kinds of data correctly', async () => {
179225
await IDB.set(ONYXKEYS.TEST_KEY, 'value', IDBKeyValProvider.store);

0 commit comments

Comments
 (0)