Skip to content

Commit 9475c57

Browse files
authored
Merge pull request Expensify#740 from callstack-internal/JKobrynski/feat/80091-prevent-storage-reads-for-ram-only-keys
[Onyx Audit] prevent storage reads for RAM-only keys
2 parents 313200b + b4f4b05 commit 9475c57

4 files changed

Lines changed: 443 additions & 2 deletions

File tree

lib/Onyx.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ function init({
5959

6060
if (shouldSyncMultipleInstances) {
6161
Storage.keepInstancesSync?.((key, value) => {
62+
// RAM-only keys should never sync from storage as they may have stale persisted data
63+
// from before the key was migrated to RAM-only.
64+
if (OnyxUtils.isRamOnlyKey(key)) {
65+
return;
66+
}
67+
6268
cache.set(key, value);
6369

6470
// Check if this is a collection member key to prevent duplicate callbacks

lib/OnyxUtils.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,14 @@ function get<TKey extends OnyxKey, TValue extends OnyxValue<TKey>>(key: TKey): P
265265
return Promise.resolve(cache.get(key) as TValue);
266266
}
267267

268+
// RAM-only keys should never read from storage (they may have stale persisted data
269+
// from before the key was migrated to RAM-only). Mark as nullish so future get() calls
270+
// short-circuit via hasCacheForKey and avoid re-running this branch.
271+
if (isRamOnlyKey(key)) {
272+
cache.addNullishStorageKey(key);
273+
return Promise.resolve(undefined as TValue);
274+
}
275+
268276
const taskName = `${TASK.GET}:${key}` as const;
269277

270278
// When a value retrieving task for this key is still running hook to it
@@ -324,6 +332,15 @@ function multiGet<TKey extends OnyxKey>(keys: CollectionKeyBase[]): Promise<Map<
324332
* These missingKeys will be later used to multiGet the data from the storage.
325333
*/
326334
for (const key of keys) {
335+
// RAM-only keys should never read from storage as they may have stale persisted data
336+
// from before the key was migrated to RAM-only.
337+
if (isRamOnlyKey(key)) {
338+
if (cache.hasCacheForKey(key)) {
339+
dataMap.set(key, cache.get(key) as OnyxValue<TKey>);
340+
}
341+
continue;
342+
}
343+
327344
const cacheValue = cache.get(key) as OnyxValue<TKey>;
328345
if (cacheValue) {
329346
dataMap.set(key, cacheValue);
@@ -441,7 +458,10 @@ function getAllKeys(): Promise<Set<OnyxKey>> {
441458

442459
// Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages
443460
const promise = Storage.getAllKeys().then((keys) => {
444-
cache.setAllKeys(keys);
461+
// Filter out RAM-only keys from storage results as they may be stale entries
462+
// from before the key was migrated to RAM-only.
463+
const filteredKeys = keys.filter((key) => !isRamOnlyKey(key));
464+
cache.setAllKeys(filteredKeys);
445465

446466
// return the updated set of keys
447467
return cache.getAllKeys();
@@ -1091,7 +1111,10 @@ function mergeInternal<TValue extends OnyxInput<OnyxKey> | undefined, TChange ex
10911111
* Merge user provided default key value pairs.
10921112
*/
10931113
function initializeWithDefaultKeyStates(): Promise<void> {
1094-
return Storage.multiGet(Object.keys(defaultKeyStates)).then((pairs) => {
1114+
// Filter out RAM-only keys from storage reads as they may have stale persisted data
1115+
// from before the key was migrated to RAM-only.
1116+
const keysToFetch = Object.keys(defaultKeyStates).filter((key) => !isRamOnlyKey(key));
1117+
return Storage.multiGet(keysToFetch).then((pairs) => {
10951118
const existingDataAsObject = Object.fromEntries(pairs) as Record<string, unknown>;
10961119

10971120
const merged = utils.fastMerge(existingDataAsObject, defaultKeyStates, {

tests/unit/onyxTest.ts

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3112,3 +3112,299 @@ describe('Onyx.init', () => {
31123112
});
31133113
});
31143114
});
3115+
3116+
// Separate describe block to control Onyx.init() per-test so we can pre-seed storage before init.
3117+
describe('RAM-only keys should not read from storage', () => {
3118+
let cache: typeof OnyxCache;
3119+
3120+
beforeEach(() => {
3121+
// Resets the deferred init task before each test.
3122+
Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask());
3123+
cache = require('../../lib/OnyxCache').default;
3124+
});
3125+
3126+
afterEach(() => {
3127+
jest.restoreAllMocks();
3128+
return Onyx.clear();
3129+
});
3130+
3131+
it('should not return stale storage data for a RAM-only key via get', async () => {
3132+
// Simulate stale data left in storage from before the key was RAM-only
3133+
await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_value');
3134+
3135+
Onyx.init({
3136+
keys: ONYX_KEYS,
3137+
ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE],
3138+
});
3139+
await act(async () => waitForPromisesToResolve());
3140+
3141+
let receivedValue: unknown;
3142+
const connection = Onyx.connect({
3143+
key: ONYX_KEYS.RAM_ONLY_TEST_KEY,
3144+
callback: (value) => {
3145+
receivedValue = value;
3146+
},
3147+
});
3148+
await act(async () => waitForPromisesToResolve());
3149+
3150+
expect(receivedValue).toBeUndefined();
3151+
expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeUndefined();
3152+
3153+
Onyx.disconnect(connection);
3154+
});
3155+
3156+
it('should not return stale storage data for RAM-only collection members via multiGet', async () => {
3157+
const collectionMember1 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`;
3158+
const collectionMember2 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`;
3159+
3160+
// Simulate stale collection members in storage
3161+
await StorageMock.setItem(collectionMember1, {name: 'stale_1'});
3162+
await StorageMock.setItem(collectionMember2, {name: 'stale_2'});
3163+
3164+
Onyx.init({
3165+
keys: ONYX_KEYS,
3166+
ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE],
3167+
});
3168+
await act(async () => waitForPromisesToResolve());
3169+
3170+
let receivedCollection: OnyxCollection<unknown>;
3171+
const connection = Onyx.connect({
3172+
key: ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION,
3173+
callback: (value) => {
3174+
receivedCollection = value;
3175+
},
3176+
waitForCollectionCallback: true,
3177+
});
3178+
await act(async () => waitForPromisesToResolve());
3179+
3180+
expect(receivedCollection).toBeUndefined();
3181+
expect(cache.get(collectionMember1)).toBeUndefined();
3182+
expect(cache.get(collectionMember2)).toBeUndefined();
3183+
3184+
Onyx.disconnect(connection);
3185+
});
3186+
3187+
it('should not include stale RAM-only keys in getAllKeys results', async () => {
3188+
// Simulate stale data in storage
3189+
await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_value');
3190+
await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, {stale: 'member'});
3191+
await StorageMock.setItem(ONYX_KEYS.OTHER_TEST, 'normal_value');
3192+
3193+
Onyx.init({
3194+
keys: ONYX_KEYS,
3195+
ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE],
3196+
});
3197+
await act(async () => waitForPromisesToResolve());
3198+
3199+
const keys = await OnyxUtils.getAllKeys();
3200+
3201+
expect(keys.has(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBe(false);
3202+
expect(keys.has(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toBe(false);
3203+
// Normal keys should still be present
3204+
expect(keys.has(ONYX_KEYS.OTHER_TEST)).toBe(true);
3205+
});
3206+
3207+
it('should not read stale storage data for RAM-only keys during initializeWithDefaultKeyStates', async () => {
3208+
// Simulate stale data for a RAM-only key that also has a default key state
3209+
await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, 'stale_value');
3210+
3211+
Onyx.init({
3212+
keys: ONYX_KEYS,
3213+
initialKeyStates: {
3214+
[ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE]: 'default_value',
3215+
},
3216+
ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE],
3217+
});
3218+
await act(async () => waitForPromisesToResolve());
3219+
3220+
// The cache should have the default value, not the stale storage value
3221+
expect(cache.get(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE)).toEqual('default_value');
3222+
});
3223+
3224+
it('should not use stale storage data as merge base for RAM-only keys', async () => {
3225+
// Simulate stale data in storage
3226+
await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, {name: 'stale', token: 'old_token'});
3227+
3228+
Onyx.init({
3229+
keys: ONYX_KEYS,
3230+
ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE],
3231+
});
3232+
await act(async () => waitForPromisesToResolve());
3233+
3234+
// Merge new data — should NOT merge with stale storage value
3235+
await Onyx.merge(ONYX_KEYS.RAM_ONLY_TEST_KEY, {name: 'new'});
3236+
3237+
// The result should only contain the merged value, not the stale token
3238+
expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual({name: 'new'});
3239+
});
3240+
3241+
it('should not read stale storage data when subscribing to individual RAM-only collection members', async () => {
3242+
const collectionMember = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`;
3243+
3244+
// Simulate stale data in storage
3245+
await StorageMock.setItem(collectionMember, {data: 'stale'});
3246+
3247+
Onyx.init({
3248+
keys: ONYX_KEYS,
3249+
ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE],
3250+
});
3251+
await act(async () => waitForPromisesToResolve());
3252+
3253+
const receivedValues: unknown[] = [];
3254+
const connection = Onyx.connect({
3255+
key: collectionMember,
3256+
callback: (value) => {
3257+
receivedValues.push(value);
3258+
},
3259+
});
3260+
await act(async () => waitForPromisesToResolve());
3261+
3262+
// Should never receive the stale value
3263+
expect(receivedValues.every((v) => v === undefined || v === null)).toBe(true);
3264+
3265+
Onyx.disconnect(connection);
3266+
});
3267+
3268+
it('should still work correctly for normal keys when RAM-only keys have stale storage data', async () => {
3269+
// Simulate both normal and RAM-only stale data in storage
3270+
await StorageMock.setItem(ONYX_KEYS.TEST_KEY, 'normal_value');
3271+
await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_ram_value');
3272+
3273+
Onyx.init({
3274+
keys: ONYX_KEYS,
3275+
ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE],
3276+
});
3277+
await act(async () => waitForPromisesToResolve());
3278+
3279+
let normalValue: unknown;
3280+
let ramOnlyValue: unknown;
3281+
3282+
const connection1 = Onyx.connect({
3283+
key: ONYX_KEYS.TEST_KEY,
3284+
callback: (value) => {
3285+
normalValue = value;
3286+
},
3287+
});
3288+
const connection2 = Onyx.connect({
3289+
key: ONYX_KEYS.RAM_ONLY_TEST_KEY,
3290+
callback: (value) => {
3291+
ramOnlyValue = value;
3292+
},
3293+
});
3294+
await act(async () => waitForPromisesToResolve());
3295+
3296+
// Normal key should read from storage as expected
3297+
expect(normalValue).toEqual('normal_value');
3298+
// RAM-only key should NOT read stale value from storage
3299+
expect(ramOnlyValue).toBeUndefined();
3300+
3301+
Onyx.disconnect(connection1);
3302+
Onyx.disconnect(connection2);
3303+
});
3304+
3305+
it('should not sync RAM-only keys from other instances via keepInstancesSync', async () => {
3306+
Onyx.init({
3307+
keys: ONYX_KEYS,
3308+
ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE],
3309+
shouldSyncMultipleInstances: true,
3310+
});
3311+
await act(async () => waitForPromisesToResolve());
3312+
3313+
// Get the callback that was passed to keepInstancesSync
3314+
const syncCallback = (StorageMock.keepInstancesSync as jest.Mock).mock.calls[0]?.[0];
3315+
expect(syncCallback).toBeDefined();
3316+
3317+
let receivedValue: unknown;
3318+
const connection = Onyx.connect({
3319+
key: ONYX_KEYS.RAM_ONLY_TEST_KEY,
3320+
callback: (value) => {
3321+
receivedValue = value;
3322+
},
3323+
});
3324+
await act(async () => waitForPromisesToResolve());
3325+
3326+
// Simulate another tab syncing a stale RAM-only key value
3327+
syncCallback(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'synced_stale_value');
3328+
await act(async () => waitForPromisesToResolve());
3329+
3330+
// The RAM-only key should NOT have been updated from the sync
3331+
expect(receivedValue).toBeUndefined();
3332+
expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeUndefined();
3333+
3334+
// Verify that normal keys still sync correctly
3335+
let normalValue: unknown;
3336+
const connection2 = Onyx.connect({
3337+
key: ONYX_KEYS.OTHER_TEST,
3338+
callback: (value) => {
3339+
normalValue = value;
3340+
},
3341+
});
3342+
await act(async () => waitForPromisesToResolve());
3343+
3344+
syncCallback(ONYX_KEYS.OTHER_TEST, 'synced_normal_value');
3345+
await act(async () => waitForPromisesToResolve());
3346+
3347+
expect(normalValue).toEqual('synced_normal_value');
3348+
3349+
Onyx.disconnect(connection);
3350+
Onyx.disconnect(connection2);
3351+
});
3352+
3353+
it('should serve RAM-only keys from cache and normal keys from storage in multiGet', async () => {
3354+
const ramOnlyMember = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`;
3355+
const normalMember = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`;
3356+
3357+
// Pre-seed storage with stale data for both normal and RAM-only keys
3358+
await StorageMock.setItem(normalMember, 'normal_from_storage');
3359+
await StorageMock.setItem(ramOnlyMember, {data: 'stale_collection_member'});
3360+
3361+
Onyx.init({
3362+
keys: ONYX_KEYS,
3363+
ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE],
3364+
});
3365+
await act(async () => waitForPromisesToResolve());
3366+
3367+
// Set a RAM-only collection member via Onyx (goes to cache only)
3368+
await Onyx.set(ramOnlyMember, {data: 'fresh_from_cache'});
3369+
3370+
// multiGet receives individual keys (e.g. collection members), not collection base keys
3371+
const result = await OnyxUtils.multiGet([normalMember, ramOnlyMember]);
3372+
3373+
// Normal key should come from storage
3374+
expect(result.get(normalMember)).toEqual('normal_from_storage');
3375+
// RAM-only collection member should come from cache, not stale storage
3376+
expect(result.get(ramOnlyMember)).toEqual({data: 'fresh_from_cache'});
3377+
});
3378+
3379+
it('should return cached value for RAM-only key after set then connect', async () => {
3380+
await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_value');
3381+
3382+
Onyx.init({
3383+
keys: ONYX_KEYS,
3384+
ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE],
3385+
});
3386+
await act(async () => waitForPromisesToResolve());
3387+
3388+
// Write a fresh value to the RAM-only key
3389+
await Onyx.set(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'fresh_value');
3390+
3391+
let receivedValue: unknown;
3392+
const connection = Onyx.connect({
3393+
key: ONYX_KEYS.RAM_ONLY_TEST_KEY,
3394+
callback: (value) => {
3395+
receivedValue = value;
3396+
},
3397+
});
3398+
await act(async () => waitForPromisesToResolve());
3399+
3400+
// Should get the fresh cached value, not the stale storage value
3401+
expect(receivedValue).toEqual('fresh_value');
3402+
expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual('fresh_value');
3403+
3404+
// Verify storage was NOT written to
3405+
const storageValue = await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY);
3406+
expect(storageValue).toEqual('stale_value');
3407+
3408+
Onyx.disconnect(connection);
3409+
});
3410+
});

0 commit comments

Comments
 (0)