diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 725dbfd77..23fae2130 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -362,11 +362,18 @@ class OnyxCache { /** * Finds the least recently accessed key that can be safely evicted from storage. + * `excludeKeys` skips keys that must not be evicted (e.g. the in-flight write's own keys, + * whose cache value is the merge base the retry depends on). */ - getKeyForEviction(): OnyxKey | undefined { + getKeyForEviction(excludeKeys?: Set): OnyxKey | undefined { // recentlyAccessedKeys is ordered from least to most recently accessed, - // so the first element is the best candidate for eviction. - return this.recentlyAccessedKeys.values().next().value; + // so the first non-excluded key is the best candidate for eviction. + for (const key of this.recentlyAccessedKeys) { + if (!excludeKeys?.has(key)) { + return key; + } + } + return undefined; } /** diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index c5c277cd7..c88b2e170 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -809,7 +809,13 @@ function reportStorageQuota(error?: Error): Promise { * - Non-retriable errors: logs an alert and resolves without retrying * - Other errors: retries the operation */ -function retryOperation(error: Error, onyxMethod: TMethod, defaultParams: Parameters[0], retryAttempt: number | undefined): Promise { +function retryOperation( + error: Error, + onyxMethod: TMethod, + defaultParams: Parameters[0], + retryAttempt: number | undefined, + inFlightKeys?: Set, +): Promise { const currentRetryAttempt = retryAttempt ?? 0; const nextRetryAttempt = currentRetryAttempt + 1; @@ -840,8 +846,10 @@ function retryOperation(error: Error, on return onyxMethod(defaultParams, nextRetryAttempt); } - // Find the least recently accessed evictable key that we can remove - const keyForRemoval = cache.getKeyForEviction(); + // Find the least recently accessed evictable key that we can remove. Never evict an in-flight + // key — its cache value is the merge base this retry depends on, so dropping it would truncate + // the write to just the delta and diverge cache from storage. + const keyForRemoval = cache.getKeyForEviction(inFlightKeys); if (!keyForRemoval) { // If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case, // then we should stop retrying as there is not much the user can do to fix this. Instead of getting them stuck in an infinite loop we @@ -1407,14 +1415,21 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom // so re-entrant callbacks (e.g. Onyx.set inside a callback) see consistent cache // and subscriber state, matching the original per-key notification semantics. cache.set(key, value); - keyChanged(key, value); + // Skip subscriber notification on retry — already notified on attempt 0. + // waitForCollectionCallback subscribers re-fire on every keyChanged by contract. + if (!retryAttempt) { + keyChanged(key, value); + } } } // One keysChanged() per collection — fires each collection-level subscriber once and lets // keysChanged() internally decide which individual member subscribers need notification. - for (const [collectionKey, batch] of collectionBatches) { - keysChanged(collectionKey as CollectionKeyBase, batch.partial, batch.previous); + // Skip on retry — already notified on attempt 0 (see same-reason comment above). + if (!retryAttempt) { + for (const [collectionKey, batch] of collectionBatches) { + keysChanged(collectionKey as CollectionKeyBase, batch.partial, batch.previous); + } } const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => { @@ -1423,8 +1438,10 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom return !OnyxKeys.isRamOnlyKey(key); }); + const inFlightKeys = new Set(keyValuePairsToSet.map(([key]) => key)); + return Storage.multiSet(keyValuePairsToStore) - .catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt)) + .catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt, inFlightKeys)) .then(() => { OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData); }); @@ -1488,7 +1505,11 @@ function setCollectionWithRetry({collectionKey, for (const [key, value] of keyValuePairs) cache.set(key, value); - keysChanged(collectionKey, mutableCollection, previousCollection); + // Skip subscriber notification on retry — already notified on attempt 0. + // waitForCollectionCallback subscribers re-fire on every keysChanged by contract. + if (!retryAttempt) { + keysChanged(collectionKey, mutableCollection, previousCollection); + } // RAM-only keys are not supposed to be saved to storage if (OnyxKeys.isRamOnlyKey(collectionKey)) { @@ -1496,8 +1517,10 @@ function setCollectionWithRetry({collectionKey, return; } + const inFlightKeys = new Set(keyValuePairs.map(([key]) => key)); + return Storage.multiSet(keyValuePairs) - .catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, {collectionKey, collection}, retryAttempt)) + .catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, {collectionKey, collection}, retryAttempt, inFlightKeys)) .then(() => { OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection); }); @@ -1628,12 +1651,17 @@ function mergeCollectionWithPatches( // write fails. const previousCollection = getCachedCollection(collectionKey, existingKeys); cache.merge(finalMergedCollection); - keysChanged(collectionKey, finalMergedCollection, previousCollection); + // Skip subscriber notification on retry — already notified on attempt 0. + // waitForCollectionCallback subscribers re-fire on every keysChanged by contract. + if (!retryAttempt) { + keysChanged(collectionKey, finalMergedCollection, previousCollection); + } const promises = []; - // New keys will be added via multiSet while existing keys will be updated using multiMerge - // This is because setting a key that doesn't exist yet with multiMerge will throw errors + // New keys go through multiSet and existing keys through multiMerge. multiMerge on a + // missing key stores the value just like multiSet across all backends; splitting them lets + // multiSet strip nested nulls (the merge layer keeps them to delete nested storage keys). // We can skip this step for RAM-only keys as they should never be saved to storage if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) { promises.push(Storage.multiMerge(keyValuePairsForExistingCollection)); @@ -1644,6 +1672,8 @@ function mergeCollectionWithPatches( promises.push(Storage.multiSet(keyValuePairsForNewCollection)); } + const inFlightKeys = new Set(Object.keys(finalMergedCollection)); + return Promise.all(promises) .catch((error) => retryOperation( @@ -1651,6 +1681,7 @@ function mergeCollectionWithPatches( mergeCollectionWithPatches, {collectionKey, collection: resultCollection as OnyxMergeCollectionInput, mergeReplaceNullPatches, isProcessingCollectionUpdate}, retryAttempt, + inFlightKeys, ), ) .then(() => { @@ -1707,15 +1738,21 @@ function partialSetCollection({collectionKey, co for (const [key, value] of keyValuePairs) cache.set(key, value); - keysChanged(collectionKey, mutableCollection, previousCollection); + // Skip subscriber notification on retry — already notified on attempt 0. + // waitForCollectionCallback subscribers re-fire on every keysChanged by contract. + if (!retryAttempt) { + keysChanged(collectionKey, mutableCollection, previousCollection); + } if (OnyxKeys.isRamOnlyKey(collectionKey)) { sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection); return; } + const inFlightKeys = new Set(keyValuePairs.map(([key]) => key)); + return Storage.multiSet(keyValuePairs) - .catch((error) => retryOperation(error, partialSetCollection, {collectionKey, collection}, retryAttempt)) + .catch((error) => retryOperation(error, partialSetCollection, {collectionKey, collection}, retryAttempt, inFlightKeys)) .then(() => { sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection); }); diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 5be3b93da..8767dca82 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -158,7 +158,10 @@ describe('OnyxUtils', () => { [routeA]: {name: 'Route A'}, } as GenericCollection); - await OnyxUtils.partialSetCollection({collectionKey: ONYXKEYS.COLLECTION.ROUTES, collection: {} as GenericCollection}); + await OnyxUtils.partialSetCollection({ + collectionKey: ONYXKEYS.COLLECTION.ROUTES, + collection: {} as GenericCollection, + }); expect(result).toEqual({ [routeA]: {name: 'Route A'}, @@ -230,9 +233,18 @@ describe('OnyxUtils', () => { const spy2 = jest.fn(); const spy3 = jest.fn(); - const conn1 = Onyx.connect({key: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, callback: spy1}); - const conn2 = Onyx.connect({key: `${ONYXKEYS.COLLECTION.TEST_KEY}2`, callback: spy2}); - const conn3 = Onyx.connect({key: `${ONYXKEYS.COLLECTION.TEST_KEY}3`, callback: spy3}); + const conn1 = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, + callback: spy1, + }); + const conn2 = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TEST_KEY}2`, + callback: spy2, + }); + const conn3 = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TEST_KEY}3`, + callback: spy3, + }); await waitForPromisesToResolve(); spy1.mockClear(); spy2.mockClear(); @@ -335,8 +347,14 @@ describe('OnyxUtils', () => { const spy1 = jest.fn(); const spy2 = jest.fn(); - const conn1 = Onyx.connect({key: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, callback: spy1}); - const conn2 = Onyx.connect({key: `${ONYXKEYS.COLLECTION.TEST_KEY}2`, callback: spy2}); + const conn1 = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, + callback: spy1, + }); + const conn2 = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TEST_KEY}2`, + callback: spy2, + }); await waitForPromisesToResolve(); spy1.mockClear(); spy2.mockClear(); @@ -389,7 +407,10 @@ describe('OnyxUtils', () => { // A subscriber for keyA synchronously calls Onyx.set() on keyB during its callback. // After multiSet completes, the cache must reflect the multiSet's value for keyB // (multiSet wins), and the keyB subscriber's last seen value must equal the cache. - await Onyx.multiSet({[ONYXKEYS.TEST_KEY]: 'initialA', [ONYXKEYS.TEST_KEY_2]: 'initialB'}); + await Onyx.multiSet({ + [ONYXKEYS.TEST_KEY]: 'initialA', + [ONYXKEYS.TEST_KEY_2]: 'initialB', + }); const callbackA = jest.fn((value: unknown) => { if (value !== 'newA') { @@ -613,8 +634,16 @@ describe('OnyxUtils', () => { const failingCallback = jest.fn(); const workingCallback = jest.fn(); - const connFailing = Onyx.connect({key: entryKey, callback: failingCallback, reuseConnection: false}); - const connWorking = Onyx.connect({key: entryKey, callback: workingCallback, reuseConnection: false}); + const connFailing = Onyx.connect({ + key: entryKey, + callback: failingCallback, + reuseConnection: false, + }); + const connWorking = Onyx.connect({ + key: entryKey, + callback: workingCallback, + reuseConnection: false, + }); await waitForPromisesToResolve(); failingCallback.mockReset(); failingCallback.mockImplementation(() => { @@ -797,9 +826,17 @@ describe('OnyxUtils', () => { it('should include usageDetails in the storage quota log when available', async () => { const logInfoSpy = jest.spyOn(Logger, 'logInfo'); - const usageDetails = {caches: 1500160, fileSystem: 1369398, indexedDB: 10419711}; + const usageDetails = { + caches: 1500160, + fileSystem: 1369398, + indexedDB: 10419711, + }; StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); - StorageMock.getDatabaseSize = jest.fn().mockResolvedValue({bytesUsed: 13289269, bytesRemaining: 5000000, usageDetails}); + StorageMock.getDatabaseSize = jest.fn().mockResolvedValue({ + bytesUsed: 13289269, + bytesRemaining: 5000000, + usageDetails, + }); await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); @@ -882,7 +919,9 @@ describe('OnyxUtils', () => { // Cache must reflect the merge regardless of the multiMerge rejection. This is the // cache-first / storage-second invariant that mergeCollectionWithPatches must honor. const cachedCollection = OnyxCache.getCollectionData(collectionKey); - expect(cachedCollection?.[existingMemberKey]).toEqual({value: 'merged'}); + expect(cachedCollection?.[existingMemberKey]).toEqual({ + value: 'merged', + }); expect(cachedCollection?.[newMemberKey]).toEqual({value: 'new'}); // Subscribers must have been notified with the merged values. @@ -933,6 +972,128 @@ describe('OnyxUtils', () => { }); }); + describe('retry side-effect idempotency', () => { + // Save originals so each test can replace StorageMock.multiMerge / StorageMock.multiSet + // with a one-shot rejecting mock that triggers retryOperation's transient-error path. + // Restoring keeps mocks from leaking into the storage-eviction describe block below. + const originalMultiMerge = StorageMock.multiMerge; + const originalMultiSet = StorageMock.multiSet; + + afterEach(() => { + StorageMock.multiMerge = originalMultiMerge; + StorageMock.multiSet = originalMultiSet; + }); + + // A retriable error: not in NON_RETRIABLE_ERRORS, not in STORAGE_ERRORS, so retryOperation + // re-enters the failing method on the next attempt. + const transientError = new Error('Transient storage error'); + + it('mergeCollection — waitForCollectionCallback subscriber fires once across retries', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const existingMemberKey = `${collectionKey}1`; + const newMemberKey = `${collectionKey}2`; + + await Onyx.set(existingMemberKey, {value: 'initial'}); + + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + waitForCollectionCallback: true, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + StorageMock.multiMerge = jest.fn(originalMultiMerge).mockRejectedValueOnce(transientError); + + await Onyx.mergeCollection(collectionKey, { + [existingMemberKey]: {value: 'merged'}, + [newMemberKey]: {value: 'new'}, + } as GenericCollection); + + // Before this fix, every retry attempt re-fired keysChanged() — and + // waitForCollectionCallback subscribers fire on every keysChanged() call by contract. + // After the fix, retries skip the keysChanged re-fire, so subscribers are notified + // exactly once per logical operation. + expect(collectionCallback).toHaveBeenCalledTimes(1); + }); + + it('Onyx.multiSet — collection subscriber fires once across retries', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const memberKey1 = `${collectionKey}1`; + const memberKey2 = `${collectionKey}2`; + + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + waitForCollectionCallback: true, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError); + + await Onyx.multiSet({ + [memberKey1]: {value: 'first'}, + [memberKey2]: {value: 'second'}, + }); + + expect(collectionCallback).toHaveBeenCalledTimes(1); + }); + + it('Onyx.setCollection — collection subscriber fires once across retries', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const memberKey1 = `${collectionKey}1`; + const memberKey2 = `${collectionKey}2`; + + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + waitForCollectionCallback: true, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError); + + await Onyx.setCollection(collectionKey, { + [memberKey1]: {value: 'first'}, + [memberKey2]: {value: 'second'}, + } as GenericCollection); + + expect(collectionCallback).toHaveBeenCalledTimes(1); + }); + + it('OnyxUtils.partialSetCollection — collection subscriber fires once across retries', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const memberKey1 = `${collectionKey}1`; + const memberKey2 = `${collectionKey}2`; + + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + waitForCollectionCallback: true, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError); + + await OnyxUtils.partialSetCollection({ + collectionKey, + collection: { + [memberKey1]: {value: 'first'}, + [memberKey2]: {value: 'second'}, + } as GenericCollection, + }); + + expect(collectionCallback).toHaveBeenCalledTimes(1); + }); + }); + describe('mergeCollection pre-warm', () => { // retryOperation tests above replace StorageMock methods without restoring them, leaving // rejecting mocks behind. Capture pristine refs at file-load time and restore in beforeEach @@ -1294,7 +1455,9 @@ describe('OnyxUtils', () => { // No more evictable candidates expect(LocalOnyxCache.getKeyForEviction()).toBeUndefined(); // Non-evictable key should still be in cache - expect(LocalOnyxCache.get(ONYXKEYS.TEST_KEY)).toEqual({test: 'not evictable'}); + expect(LocalOnyxCache.get(ONYXKEYS.TEST_KEY)).toEqual({ + test: 'not evictable', + }); }); it('should not add collection keys to eviction candidates, only their members', async () => { @@ -1320,8 +1483,12 @@ describe('OnyxUtils', () => { LocalOnyxCache = require('../../lib/OnyxCache').default; const storage = require('../../lib/storage').default; - await storage.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}pre1`, {id: 'pre1'}); - await storage.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}pre2`, {id: 'pre2'}); + await storage.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}pre1`, { + id: 'pre1', + }); + await storage.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}pre2`, { + id: 'pre2', + }); // Init — addEvictableKeysToRecentlyAccessedList should seed them LocalOnyx.init({ @@ -1349,6 +1516,116 @@ describe('OnyxUtils', () => { expect(logInfoSpy).toHaveBeenCalledWith(`Out of storage. Evicting least recently accessed key (${key1}) and retrying. Error: ${diskFullError}`); expect(logInfoSpy).toHaveBeenCalledWith(`Storage Quota Check -- bytesUsed: 0 bytesRemaining: Infinity. Original error: ${diskFullError}`); }); + + it('multiSet — eviction of an UNRELATED key still notifies its subscribers (codex regression guard)', async () => { + const evictableKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; + const writeKey = `${ONYXKEYS.COLLECTION.TEST_KEY}2`; + + // Seed the evictable key first so it becomes the LRU evictable. The subsequent multiSet + // writes a DIFFERENT key, so the evicted key is unrelated to the in-flight write. + await LocalOnyx.set(evictableKey, {value: 'will-be-evicted'}); + expect(LocalOnyxCache.getKeyForEviction()).toBe(evictableKey); + + const subscriberCalls: unknown[] = []; + LocalOnyx.connect({ + key: evictableKey, + callback: (value) => subscriberCalls.push(value), + }); + await waitForPromisesToResolve(); + subscriberCalls.length = 0; + + // Storage.multiSet rejects once with disk-full, then succeeds on retry. + LocalStorageMock.multiSet = jest.fn(LocalStorageMock.multiSet).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiSet); + + await LocalOnyx.multiSet({[writeKey]: {value: 'new'}}); + + // evictableKey was the LRU evictable, so retryOperation evicted it. It's not in the + // in-flight write's keys, so the retry's cache.set won't restore it — subscribers MUST + // see keyChanged(undefined) so they reflect the genuine removal (not stale value). + expect(LocalOnyxCache.hasCacheForKey(evictableKey)).toBe(false); + expect(subscriberCalls.at(-1)).toBeUndefined(); + }); + + it('multiSet — eviction of an IN-FLIGHT key does not strand its subscriber', async () => { + const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; + + // Seed memberKey so it becomes the LRU evictable. The multiSet below writes to the SAME + // key, so eviction picks an in-flight key. + await LocalOnyx.set(memberKey, {value: 'original'}); + expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey); + + const subscriberCalls: unknown[] = []; + LocalOnyx.connect({ + key: memberKey, + callback: (value) => subscriberCalls.push(value), + }); + await waitForPromisesToResolve(); + subscriberCalls.length = 0; + + LocalStorageMock.multiSet = jest.fn(LocalStorageMock.multiSet).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiSet); + + await LocalOnyx.multiSet({[memberKey]: {value: 'updated'}}); + + // The in-flight key is excluded from eviction, so its cache value (the merge base) is + // never dropped. Subscriber's last value is the new value, never a transient undefined. + expect(LocalOnyxCache.get(memberKey)).toEqual({value: 'updated'}); + expect(subscriberCalls.at(-1)).toEqual({value: 'updated'}); + // Subscriber should never have seen undefined in the middle of the eviction-retry cycle. + expect(subscriberCalls).not.toContain(undefined); + }); + + it('mergeCollection — evicts an unrelated key, not the in-flight key, so its fields survive', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const memberKey = `${collectionKey}1`; + const unrelatedKey = `${collectionKey}2`; + + // Seed the in-flight member with extra fields, plus a separate evictable key. The merge + // only touches memberKey; the unrelated key is the genuine eviction target. + await LocalOnyx.set(memberKey, {id: 1, value: 'orig'}); + await LocalOnyx.set(unrelatedKey, {value: 'evict-me'}); + + const memberCalls: unknown[] = []; + LocalOnyx.connect({key: memberKey, callback: (value) => memberCalls.push(value)}); + await waitForPromisesToResolve(); + memberCalls.length = 0; + + // Storage.multiMerge rejects once with disk-full, then succeeds on retry. + LocalStorageMock.multiMerge = jest.fn(LocalStorageMock.multiMerge).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiMerge); + + await LocalOnyx.mergeCollection(collectionKey, {[memberKey]: {value: 'merged'}} as GenericCollection); + + // The old code evicted the in-flight key and re-ran the merge against an empty cache, + // collapsing {id: 1, value: 'orig'} + {value: 'merged'} to just {value: 'merged'}. Now + // the in-flight key is protected, so its pre-existing {id: 1} survives. + expect(LocalOnyxCache.get(memberKey)).toEqual({id: 1, value: 'merged'}); + expect(memberCalls.at(-1)).toEqual({id: 1, value: 'merged'}); + expect(memberCalls).not.toContain(undefined); + // The unrelated key was the genuine eviction target. + expect(LocalOnyxCache.hasCacheForKey(unrelatedKey)).toBe(false); + }); + + it('mergeCollection — does not truncate the in-flight key when it is the only evictable key', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const memberKey = `${collectionKey}1`; + + await LocalOnyx.set(memberKey, {id: 1, value: 'orig'}); + expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey); + + const memberCalls: unknown[] = []; + LocalOnyx.connect({key: memberKey, callback: (value) => memberCalls.push(value)}); + await waitForPromisesToResolve(); + memberCalls.length = 0; + + // The only evictable key is the in-flight one, which is now excluded — so retryOperation + // finds no acceptable key and reports the quota instead of dropping (and truncating) it. + LocalStorageMock.multiMerge = jest.fn(LocalStorageMock.multiMerge).mockRejectedValue(diskFullError); + + await LocalOnyx.mergeCollection(collectionKey, {[memberKey]: {value: 'merged'}} as GenericCollection); + + expect(LocalOnyxCache.get(memberKey)).toEqual({id: 1, value: 'merged'}); + expect(memberCalls.at(-1)).toEqual({id: 1, value: 'merged'}); + expect(memberCalls).not.toContain(undefined); + }); }); describe('afterInit', () => { diff --git a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts index ca34611f9..66e2d2b0d 100644 --- a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts +++ b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts @@ -172,6 +172,11 @@ describe('IDBKeyValProvider', () => { ), ).toEqual(expectedEntries.map((e) => (e[1] === null ? undefined : e[1]))); }); + + it('should insert a new record when key does not exist', async () => { + await IDBKeyValProvider.multiMerge([[ONYXKEYS.TEST_KEY_2, {fresh: true}]]); + expect(await IDBKeyValProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual({fresh: true}); + }); }); describe('mergeItem', () => {