Skip to content

Commit 65788d4

Browse files
committed
Merge branch 'main' into feature/onyxutils-get-synchronous
2 parents 51f93a2 + 27c9456 commit 65788d4

5 files changed

Lines changed: 112 additions & 11 deletions

File tree

lib/OnyxUtils.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -611,13 +611,13 @@ function remove<TKey extends OnyxKey>(key: TKey, isProcessingCollectionUpdate?:
611611
return Storage.removeItem(key).then(() => undefined);
612612
}
613613

614-
function reportStorageQuota(): Promise<void> {
614+
function reportStorageQuota(error?: Error): Promise<void> {
615615
return Storage.getDatabaseSize()
616616
.then(({bytesUsed, bytesRemaining}) => {
617-
Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} bytesRemaining: ${bytesRemaining}`);
617+
Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} bytesRemaining: ${bytesRemaining}. Original error: ${error}`);
618618
})
619619
.catch((dbSizeError) => {
620-
Logger.logAlert(`Unable to get database size. Error: ${dbSizeError}`);
620+
Logger.logAlert(`Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${error}`);
621621
});
622622
}
623623

@@ -634,7 +634,7 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, on
634634
Logger.logInfo(`Failed to save to storage. Error: ${error}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${currentRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`);
635635

636636
if (error && Str.startsWith(error.message, "Failed to execute 'put' on 'IDBObjectStore'")) {
637-
Logger.logAlert('Attempted to set invalid data set in Onyx. Please ensure all data is serializable.');
637+
Logger.logAlert(`Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${error}`);
638638
throw error;
639639
}
640640

@@ -658,13 +658,13 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, on
658658
// If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case,
659659
// 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
660660
// will allow this write to be skipped.
661-
Logger.logAlert('Out of storage. But found no acceptable keys to remove.');
662-
return reportStorageQuota();
661+
Logger.logAlert(`Out of storage. But found no acceptable keys to remove. Error: ${error}`);
662+
return reportStorageQuota(error);
663663
}
664664

665665
// Remove the least recently accessed key and retry.
666-
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying.`);
667-
reportStorageQuota();
666+
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`);
667+
reportStorageQuota(error);
668668

669669
// @ts-expect-error No overload matches this call.
670670
return remove(keyForRemoval).then(() => onyxMethod(defaultParams, nextRetryAttempt));

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-native-onyx",
3-
"version": "3.0.67",
3+
"version": "3.0.69",
44
"author": "Expensify, Inc.",
55
"homepage": "https://expensify.com",
66
"description": "State management for React Native",

tests/unit/onyxTest.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3475,3 +3475,56 @@ describe('RAM-only keys should not read from storage', () => {
34753475
Onyx.disconnect(connection);
34763476
});
34773477
});
3478+
3479+
describe('get() should prefer cache over stale storage', () => {
3480+
let cache: typeof OnyxCache;
3481+
3482+
beforeEach(() => {
3483+
Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask());
3484+
cache = require('../../lib/OnyxCache').default;
3485+
Onyx.init({keys: ONYX_KEYS});
3486+
});
3487+
3488+
afterEach(() => {
3489+
jest.restoreAllMocks();
3490+
return Onyx.clear();
3491+
});
3492+
3493+
it('should preserve data from Onyx.update when a concurrent Onyx.merge fires before cache is written', async () => {
3494+
const member1 = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`;
3495+
const member2 = `${ONYX_KEYS.COLLECTION.TEST_KEY}2`;
3496+
3497+
// Delay getItem for member1 to simulate slow Native storage (returns null before the write lands)
3498+
const getItemMock = StorageMock.getItem as jest.Mock;
3499+
const originalGetItem = getItemMock.getMockImplementation()!;
3500+
getItemMock.mockImplementation((key: OnyxKey) => {
3501+
if (key === member1) {
3502+
return new Promise<undefined>((resolve) => {
3503+
setTimeout(() => resolve(undefined), 50);
3504+
});
3505+
}
3506+
return originalGetItem(key);
3507+
});
3508+
3509+
// 2+ collection keys get batched into mergeCollectionWithPatches (deferred cache write)
3510+
const updatePromise = Onyx.update([
3511+
{onyxMethod: Onyx.METHOD.MERGE, key: member1, value: {isOptimistic: true, name: 'first'}},
3512+
{onyxMethod: Onyx.METHOD.MERGE, key: member2, value: {isOptimistic: true, name: 'second'}},
3513+
]);
3514+
3515+
// Concurrent merge fires before cache write — its get() hits the delayed storage mock
3516+
const mergePromise = Onyx.merge(member1, {lastVisitTime: '2025-01-01'});
3517+
3518+
await act(async () => {
3519+
await updatePromise;
3520+
await mergePromise;
3521+
await new Promise<void>((resolve) => {
3522+
setTimeout(resolve, 100);
3523+
});
3524+
});
3525+
3526+
const value = cache.get(member1);
3527+
expect(value).toHaveProperty('isOptimistic', true);
3528+
expect(value).toHaveProperty('lastVisitTime', '2025-01-01');
3529+
});
3530+
});

tests/unit/onyxUtilsTest.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import utils from '../../lib/utils';
66
import type {Collection, OnyxCollection} from '../../lib/types';
77
import type GenericCollection from '../utils/GenericCollection';
88
import OnyxCache from '../../lib/OnyxCache';
9+
import * as Logger from '../../lib/Logger';
910
import StorageMock from '../../lib/storage';
1011
import createDeferredTask from '../../lib/createDeferredTask';
1112
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
@@ -463,6 +464,37 @@ describe('OnyxUtils', () => {
463464
expect(retryOperationSpy).toHaveBeenCalledTimes(1);
464465
});
465466

467+
it('should include the error in logAlert for IDBObjectStore invalid data errors', async () => {
468+
const logAlertSpy = jest.spyOn(Logger, 'logAlert');
469+
StorageMock.setItem = jest.fn().mockRejectedValueOnce(invalidDataError);
470+
471+
await expect(Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'})).rejects.toThrow(invalidDataError);
472+
473+
expect(logAlertSpy).toHaveBeenCalledWith(`Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${invalidDataError}`);
474+
});
475+
476+
it('should include the error in logs when out of storage with no evictable keys', async () => {
477+
const logAlertSpy = jest.spyOn(Logger, 'logAlert');
478+
const logInfoSpy = jest.spyOn(Logger, 'logInfo');
479+
StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError);
480+
481+
await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'});
482+
483+
expect(logAlertSpy).toHaveBeenCalledWith(`Out of storage. But found no acceptable keys to remove. Error: ${diskFullError}`);
484+
expect(logInfoSpy).toHaveBeenCalledWith(`Storage Quota Check -- bytesUsed: 0 bytesRemaining: Infinity. Original error: ${diskFullError}`);
485+
});
486+
487+
it('should include the error in logAlert when out of storage and getDatabaseSize fails', async () => {
488+
const dbSizeError = new Error('Failed to estimate storage');
489+
const logAlertSpy = jest.spyOn(Logger, 'logAlert');
490+
StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError);
491+
StorageMock.getDatabaseSize = jest.fn().mockRejectedValue(dbSizeError);
492+
493+
await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'});
494+
495+
expect(logAlertSpy).toHaveBeenCalledWith(`Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${diskFullError}`);
496+
});
497+
466498
it('should not re-add an evicted key to recentlyAccessedKeys after removal', async () => {
467499
// Re-init with evictable keys so getKeyForEviction() has something to return
468500
Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask());
@@ -490,6 +522,7 @@ describe('OnyxUtils', () => {
490522
let LocalOnyxUtils: typeof OnyxUtils;
491523
let LocalOnyxCache: typeof OnyxCache;
492524
let LocalStorageMock: typeof StorageMock;
525+
let LocalLogger: typeof Logger;
493526

494527
// Reset all modules to get fresh singletons (OnyxCache, OnyxUtils, etc.)
495528
// then re-init Onyx with evictableKeys configured
@@ -500,6 +533,7 @@ describe('OnyxUtils', () => {
500533
LocalOnyxUtils = require('../../lib/OnyxUtils').default;
501534
LocalOnyxCache = require('../../lib/OnyxCache').default;
502535
LocalStorageMock = require('../../lib/storage').default;
536+
LocalLogger = require('../../lib/Logger');
503537

504538
LocalOnyx.init({
505539
keys: ONYXKEYS,
@@ -605,6 +639,20 @@ describe('OnyxUtils', () => {
605639
expect(keyForEviction).toBeDefined();
606640
expect(keyForEviction?.startsWith(ONYXKEYS.COLLECTION.TEST_KEY)).toBe(true);
607641
});
642+
643+
it('should include the error in logs when evicting a key', async () => {
644+
const logInfoSpy = jest.spyOn(LocalLogger, 'logInfo');
645+
const key1 = `${ONYXKEYS.COLLECTION.TEST_KEY}1`;
646+
647+
await LocalOnyx.set(key1, {id: 1});
648+
649+
LocalStorageMock.setItem = jest.fn(LocalStorageMock.setItem).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.setItem);
650+
651+
await LocalOnyx.set(ONYXKEYS.TEST_KEY, {test: 'data'});
652+
653+
expect(logInfoSpy).toHaveBeenCalledWith(`Out of storage. Evicting least recently accessed key (${key1}) and retrying. Error: ${diskFullError}`);
654+
expect(logInfoSpy).toHaveBeenCalledWith(`Storage Quota Check -- bytesUsed: 0 bytesRemaining: Infinity. Original error: ${diskFullError}`);
655+
});
608656
});
609657

610658
describe('afterInit', () => {

0 commit comments

Comments
 (0)