Skip to content

Commit 8d76faf

Browse files
committed
Merge branch 'main' into feature/structural-sharing-cache-pr-4
2 parents f1e1ba1 + 27c9456 commit 8d76faf

5 files changed

Lines changed: 118 additions & 11 deletions

File tree

lib/OnyxUtils.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,12 @@ function get<TKey extends OnyxKey, TValue extends OnyxValue<TKey>>(key: TKey): P
288288
}
289289
}
290290

291+
// Prefer cache over stale storage if a concurrent write populated it during the read.
292+
const cachedValue = cache.get(key) as TValue;
293+
if (cachedValue !== undefined) {
294+
return cachedValue;
295+
}
296+
291297
if (val === undefined) {
292298
cache.addNullishStorageKey(key);
293299
return undefined;
@@ -742,13 +748,13 @@ function remove<TKey extends OnyxKey>(key: TKey, isProcessingCollectionUpdate?:
742748
return Storage.removeItem(key).then(() => undefined);
743749
}
744750

745-
function reportStorageQuota(): Promise<void> {
751+
function reportStorageQuota(error?: Error): Promise<void> {
746752
return Storage.getDatabaseSize()
747753
.then(({bytesUsed, bytesRemaining}) => {
748-
Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} bytesRemaining: ${bytesRemaining}`);
754+
Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} bytesRemaining: ${bytesRemaining}. Original error: ${error}`);
749755
})
750756
.catch((dbSizeError) => {
751-
Logger.logAlert(`Unable to get database size. Error: ${dbSizeError}`);
757+
Logger.logAlert(`Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${error}`);
752758
});
753759
}
754760

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

767773
if (error && Str.startsWith(error.message, "Failed to execute 'put' on 'IDBObjectStore'")) {
768-
Logger.logAlert('Attempted to set invalid data set in Onyx. Please ensure all data is serializable.');
774+
Logger.logAlert(`Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${error}`);
769775
throw error;
770776
}
771777

@@ -789,13 +795,13 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, on
789795
// If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case,
790796
// 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
791797
// will allow this write to be skipped.
792-
Logger.logAlert('Out of storage. But found no acceptable keys to remove.');
793-
return reportStorageQuota();
798+
Logger.logAlert(`Out of storage. But found no acceptable keys to remove. Error: ${error}`);
799+
return reportStorageQuota(error);
794800
}
795801

796802
// Remove the least recently accessed key and retry.
797-
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying.`);
798-
reportStorageQuota();
803+
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`);
804+
reportStorageQuota(error);
799805

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

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)