From f00b48f701ea9f5e37682471f64a87c961a83bf8 Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Thu, 19 Feb 2026 14:42:59 +0100 Subject: [PATCH 1/3] prevent storage reads for RAM-only keys --- lib/Onyx.ts | 6 + lib/OnyxUtils.ts | 27 +++- tests/unit/onyxTest.ts | 306 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 2 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index e6c7c6a3..e25b9107 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -59,6 +59,12 @@ function init({ if (shouldSyncMultipleInstances) { Storage.keepInstancesSync?.((key, value) => { + // RAM-only keys should never sync from storage as they may have stale persisted data + // from before the key was migrated to RAM-only. + if (OnyxUtils.isRamOnlyKey(key)) { + return; + } + cache.set(key, value); // Check if this is a collection member key to prevent duplicate callbacks diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 01553a3e..80f1e973 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -265,6 +265,14 @@ function get>(key: TKey): P return Promise.resolve(cache.get(key) as TValue); } + // RAM-only keys should never read from storage (they may have stale persisted data + // from before the key was migrated to RAM-only). Mark as nullish so future get() calls + // short-circuit via hasCacheForKey and avoid re-running this branch. + if (isRamOnlyKey(key)) { + cache.addNullishStorageKey(key); + return Promise.resolve(undefined as TValue); + } + const taskName = `${TASK.GET}:${key}` as const; // When a value retrieving task for this key is still running hook to it @@ -324,6 +332,15 @@ function multiGet(keys: CollectionKeyBase[]): Promise); + } + continue; + } + const cacheValue = cache.get(key) as OnyxValue; if (cacheValue) { dataMap.set(key, cacheValue); @@ -441,7 +458,10 @@ function getAllKeys(): Promise> { // Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages const promise = Storage.getAllKeys().then((keys) => { - cache.setAllKeys(keys); + // Filter out RAM-only keys from storage results as they may be stale entries + // from before the key was migrated to RAM-only. + const filteredKeys = keys.filter((key) => !isRamOnlyKey(key)); + cache.setAllKeys(filteredKeys); // return the updated set of keys return cache.getAllKeys(); @@ -1101,7 +1121,10 @@ function mergeInternal | undefined, TChange ex * Merge user provided default key value pairs. */ function initializeWithDefaultKeyStates(): Promise { - return Storage.multiGet(Object.keys(defaultKeyStates)).then((pairs) => { + // Filter out RAM-only keys from storage reads as they may have stale persisted data + // from before the key was migrated to RAM-only. + const keysToFetch = Object.keys(defaultKeyStates).filter((key) => !isRamOnlyKey(key)); + return Storage.multiGet(keysToFetch).then((pairs) => { const existingDataAsObject = Object.fromEntries(pairs) as Record; const merged = utils.fastMerge(existingDataAsObject, defaultKeyStates, { diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index f66b0dc7..176e1bd1 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -3112,3 +3112,309 @@ describe('Onyx.init', () => { }); }); }); + +// Separate describe block to control Onyx.init() per-test so we can pre-seed storage before init. +describe('RAM-only keys should not read from storage', () => { + const RAM_ONLY_KEYS = { + TEST_KEY: 'test', + OTHER_TEST: 'otherTest', + COLLECTION: { + TEST_KEY: 'test_', + RAM_ONLY_COLLECTION: 'ramOnlyCollection_', + }, + RAM_ONLY_KEY: 'ramOnlyKey', + RAM_ONLY_WITH_INITIAL_VALUE: 'ramOnlyWithInitialValue', + }; + + let cache: typeof OnyxCache; + + beforeEach(() => { + Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); + cache = require('../../lib/OnyxCache').default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + return Onyx.clear(); + }); + + it('should not return stale storage data for a RAM-only key via get', async () => { + // Simulate stale data left in storage from before the key was RAM-only + await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'stale_value'); + + Onyx.init({ + keys: RAM_ONLY_KEYS, + ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + let receivedValue: unknown; + const connection = Onyx.connect({ + key: RAM_ONLY_KEYS.RAM_ONLY_KEY, + callback: (value) => { + receivedValue = value; + }, + }); + await act(async () => waitForPromisesToResolve()); + + expect(receivedValue).toBeUndefined(); + expect(cache.get(RAM_ONLY_KEYS.RAM_ONLY_KEY)).toBeUndefined(); + + Onyx.disconnect(connection); + }); + + it('should not return stale storage data for RAM-only collection members via multiGet', async () => { + const collectionMember1 = `${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + const collectionMember2 = `${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`; + + // Simulate stale collection members in storage + await StorageMock.setItem(collectionMember1, {name: 'stale_1'}); + await StorageMock.setItem(collectionMember2, {name: 'stale_2'}); + + Onyx.init({ + keys: RAM_ONLY_KEYS, + ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + let receivedCollection: OnyxCollection; + const connection = Onyx.connect({ + key: RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, + callback: (value) => { + receivedCollection = value; + }, + waitForCollectionCallback: true, + }); + await act(async () => waitForPromisesToResolve()); + + expect(receivedCollection!).toBeUndefined(); + expect(cache.get(collectionMember1)).toBeUndefined(); + expect(cache.get(collectionMember2)).toBeUndefined(); + + Onyx.disconnect(connection); + }); + + it('should not include stale RAM-only keys in getAllKeys results', async () => { + // Simulate stale data in storage + await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'stale_value'); + await StorageMock.setItem(`${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, {stale: 'member'}); + await StorageMock.setItem(RAM_ONLY_KEYS.OTHER_TEST, 'normal_value'); + + Onyx.init({ + keys: RAM_ONLY_KEYS, + ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + const keys = await OnyxUtils.getAllKeys(); + + expect(keys.has(RAM_ONLY_KEYS.RAM_ONLY_KEY)).toBe(false); + expect(keys.has(`${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toBe(false); + // Normal keys should still be present + expect(keys.has(RAM_ONLY_KEYS.OTHER_TEST)).toBe(true); + }); + + it('should not read stale storage data for RAM-only keys during initializeWithDefaultKeyStates', async () => { + // Simulate stale data for a RAM-only key that also has a default key state + await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, 'stale_value'); + + Onyx.init({ + keys: RAM_ONLY_KEYS, + initialKeyStates: { + [RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE]: 'default_value', + }, + ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + // The cache should have the default value, not the stale storage value + expect(cache.get(RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE)).toEqual('default_value'); + }); + + it('should not use stale storage data as merge base for RAM-only keys', async () => { + // Simulate stale data in storage + await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_KEY, {name: 'stale', token: 'old_token'}); + + Onyx.init({ + keys: RAM_ONLY_KEYS, + ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + // Merge new data — should NOT merge with stale storage value + await Onyx.merge(RAM_ONLY_KEYS.RAM_ONLY_KEY, {name: 'new'}); + + // The result should only contain the merged value, not the stale token + expect(cache.get(RAM_ONLY_KEYS.RAM_ONLY_KEY)).toEqual({name: 'new'}); + }); + + it('should not read stale storage data when subscribing to individual RAM-only collection members', async () => { + const collectionMember = `${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + + // Simulate stale data in storage + await StorageMock.setItem(collectionMember, {data: 'stale'}); + + Onyx.init({ + keys: RAM_ONLY_KEYS, + ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + const receivedValues: unknown[] = []; + const connection = Onyx.connect({ + key: collectionMember, + callback: (value) => { + receivedValues.push(value); + }, + }); + await act(async () => waitForPromisesToResolve()); + + // Should never receive the stale value + expect(receivedValues.every((v) => v === undefined || v === null)).toBe(true); + + Onyx.disconnect(connection); + }); + + it('should still work correctly for normal keys when RAM-only keys have stale storage data', async () => { + // Simulate both normal and RAM-only stale data in storage + await StorageMock.setItem(RAM_ONLY_KEYS.TEST_KEY, 'normal_value'); + await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'stale_ram_value'); + + Onyx.init({ + keys: RAM_ONLY_KEYS, + ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + let normalValue: unknown; + let ramOnlyValue: unknown; + + const connection1 = Onyx.connect({ + key: RAM_ONLY_KEYS.TEST_KEY, + callback: (value) => { + normalValue = value; + }, + }); + const connection2 = Onyx.connect({ + key: RAM_ONLY_KEYS.RAM_ONLY_KEY, + callback: (value) => { + ramOnlyValue = value; + }, + }); + await act(async () => waitForPromisesToResolve()); + + // Normal key should read from storage as expected + expect(normalValue).toEqual('normal_value'); + // RAM-only key should NOT read stale value from storage + expect(ramOnlyValue).toBeUndefined(); + + Onyx.disconnect(connection1); + Onyx.disconnect(connection2); + }); + + it('should not sync RAM-only keys from other instances via keepInstancesSync', async () => { + Onyx.init({ + keys: RAM_ONLY_KEYS, + ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + shouldSyncMultipleInstances: true, + }); + await act(async () => waitForPromisesToResolve()); + + // Get the callback that was passed to keepInstancesSync + const syncCallback = (StorageMock.keepInstancesSync as jest.Mock).mock.calls[0]?.[0]; + expect(syncCallback).toBeDefined(); + + let receivedValue: unknown; + const connection = Onyx.connect({ + key: RAM_ONLY_KEYS.RAM_ONLY_KEY, + callback: (value) => { + receivedValue = value; + }, + }); + await act(async () => waitForPromisesToResolve()); + + // Simulate another tab syncing a stale RAM-only key value + syncCallback(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'synced_stale_value'); + await act(async () => waitForPromisesToResolve()); + + // The RAM-only key should NOT have been updated from the sync + expect(receivedValue).toBeUndefined(); + expect(cache.get(RAM_ONLY_KEYS.RAM_ONLY_KEY)).toBeUndefined(); + + // Verify that normal keys still sync correctly + let normalValue: unknown; + const connection2 = Onyx.connect({ + key: RAM_ONLY_KEYS.OTHER_TEST, + callback: (value) => { + normalValue = value; + }, + }); + await act(async () => waitForPromisesToResolve()); + + syncCallback(RAM_ONLY_KEYS.OTHER_TEST, 'synced_normal_value'); + await act(async () => waitForPromisesToResolve()); + + expect(normalValue).toEqual('synced_normal_value'); + + Onyx.disconnect(connection); + Onyx.disconnect(connection2); + }); + + it('should serve RAM-only keys from cache and normal keys from storage in multiGet', async () => { + const ramOnlyMember = `${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + const normalMember = `${RAM_ONLY_KEYS.COLLECTION.TEST_KEY}1`; + + // Pre-seed storage with stale data for both normal and RAM-only keys + await StorageMock.setItem(normalMember, 'normal_from_storage'); + await StorageMock.setItem(ramOnlyMember, {data: 'stale_collection_member'}); + + Onyx.init({ + keys: RAM_ONLY_KEYS, + ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + // Set a RAM-only collection member via Onyx (goes to cache only) + await Onyx.set(ramOnlyMember, {data: 'fresh_from_cache'}); + + // multiGet receives individual keys (e.g. collection members), not collection base keys + const result = await OnyxUtils.multiGet([normalMember, ramOnlyMember]); + + // Normal key should come from storage + expect(result.get(normalMember)).toEqual('normal_from_storage'); + // RAM-only collection member should come from cache, not stale storage + expect(result.get(ramOnlyMember)).toEqual({data: 'fresh_from_cache'}); + }); + + it('should return cached value for RAM-only key after set then connect', async () => { + await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'stale_value'); + + Onyx.init({ + keys: RAM_ONLY_KEYS, + ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + // Write a fresh value to the RAM-only key + await Onyx.set(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'fresh_value'); + + let receivedValue: unknown; + const connection = Onyx.connect({ + key: RAM_ONLY_KEYS.RAM_ONLY_KEY, + callback: (value) => { + receivedValue = value; + }, + }); + await act(async () => waitForPromisesToResolve()); + + // Should get the fresh cached value, not the stale storage value + expect(receivedValue).toEqual('fresh_value'); + expect(cache.get(RAM_ONLY_KEYS.RAM_ONLY_KEY)).toEqual('fresh_value'); + + // Verify storage was NOT written to + const storageValue = await StorageMock.getItem(RAM_ONLY_KEYS.RAM_ONLY_KEY); + expect(storageValue).toEqual('stale_value'); + + Onyx.disconnect(connection); + }); +}); From edd14c6b4ed015b8bfb936b7497b5e514f90e808 Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Fri, 20 Feb 2026 13:13:00 +0100 Subject: [PATCH 2/3] address review comments --- tests/unit/onyxTest.ts | 121 +++++++++++++++++--------------------- tests/unit/useOnyxTest.ts | 121 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 66 deletions(-) diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index 176e1bd1..6cc027e6 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -3115,17 +3115,6 @@ describe('Onyx.init', () => { // Separate describe block to control Onyx.init() per-test so we can pre-seed storage before init. describe('RAM-only keys should not read from storage', () => { - const RAM_ONLY_KEYS = { - TEST_KEY: 'test', - OTHER_TEST: 'otherTest', - COLLECTION: { - TEST_KEY: 'test_', - RAM_ONLY_COLLECTION: 'ramOnlyCollection_', - }, - RAM_ONLY_KEY: 'ramOnlyKey', - RAM_ONLY_WITH_INITIAL_VALUE: 'ramOnlyWithInitialValue', - }; - let cache: typeof OnyxCache; beforeEach(() => { @@ -3140,17 +3129,17 @@ describe('RAM-only keys should not read from storage', () => { it('should not return stale storage data for a RAM-only key via get', async () => { // Simulate stale data left in storage from before the key was RAM-only - await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'stale_value'); + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_value'); Onyx.init({ - keys: RAM_ONLY_KEYS, - ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], }); await act(async () => waitForPromisesToResolve()); let receivedValue: unknown; const connection = Onyx.connect({ - key: RAM_ONLY_KEYS.RAM_ONLY_KEY, + key: ONYX_KEYS.RAM_ONLY_TEST_KEY, callback: (value) => { receivedValue = value; }, @@ -3158,28 +3147,28 @@ describe('RAM-only keys should not read from storage', () => { await act(async () => waitForPromisesToResolve()); expect(receivedValue).toBeUndefined(); - expect(cache.get(RAM_ONLY_KEYS.RAM_ONLY_KEY)).toBeUndefined(); + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeUndefined(); Onyx.disconnect(connection); }); it('should not return stale storage data for RAM-only collection members via multiGet', async () => { - const collectionMember1 = `${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; - const collectionMember2 = `${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`; + const collectionMember1 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + const collectionMember2 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`; // Simulate stale collection members in storage await StorageMock.setItem(collectionMember1, {name: 'stale_1'}); await StorageMock.setItem(collectionMember2, {name: 'stale_2'}); Onyx.init({ - keys: RAM_ONLY_KEYS, - ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], }); await act(async () => waitForPromisesToResolve()); let receivedCollection: OnyxCollection; const connection = Onyx.connect({ - key: RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, + key: ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, callback: (value) => { receivedCollection = value; }, @@ -3196,67 +3185,67 @@ describe('RAM-only keys should not read from storage', () => { it('should not include stale RAM-only keys in getAllKeys results', async () => { // Simulate stale data in storage - await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'stale_value'); - await StorageMock.setItem(`${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, {stale: 'member'}); - await StorageMock.setItem(RAM_ONLY_KEYS.OTHER_TEST, 'normal_value'); + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_value'); + await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, {stale: 'member'}); + await StorageMock.setItem(ONYX_KEYS.OTHER_TEST, 'normal_value'); Onyx.init({ - keys: RAM_ONLY_KEYS, - ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], }); await act(async () => waitForPromisesToResolve()); const keys = await OnyxUtils.getAllKeys(); - expect(keys.has(RAM_ONLY_KEYS.RAM_ONLY_KEY)).toBe(false); - expect(keys.has(`${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toBe(false); + expect(keys.has(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBe(false); + expect(keys.has(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toBe(false); // Normal keys should still be present - expect(keys.has(RAM_ONLY_KEYS.OTHER_TEST)).toBe(true); + expect(keys.has(ONYX_KEYS.OTHER_TEST)).toBe(true); }); it('should not read stale storage data for RAM-only keys during initializeWithDefaultKeyStates', async () => { // Simulate stale data for a RAM-only key that also has a default key state - await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, 'stale_value'); + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, 'stale_value'); Onyx.init({ - keys: RAM_ONLY_KEYS, + keys: ONYX_KEYS, initialKeyStates: { - [RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE]: 'default_value', + [ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE]: 'default_value', }, - ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], }); await act(async () => waitForPromisesToResolve()); // The cache should have the default value, not the stale storage value - expect(cache.get(RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE)).toEqual('default_value'); + expect(cache.get(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE)).toEqual('default_value'); }); it('should not use stale storage data as merge base for RAM-only keys', async () => { // Simulate stale data in storage - await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_KEY, {name: 'stale', token: 'old_token'}); + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, {name: 'stale', token: 'old_token'}); Onyx.init({ - keys: RAM_ONLY_KEYS, - ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], }); await act(async () => waitForPromisesToResolve()); // Merge new data — should NOT merge with stale storage value - await Onyx.merge(RAM_ONLY_KEYS.RAM_ONLY_KEY, {name: 'new'}); + await Onyx.merge(ONYX_KEYS.RAM_ONLY_TEST_KEY, {name: 'new'}); // The result should only contain the merged value, not the stale token - expect(cache.get(RAM_ONLY_KEYS.RAM_ONLY_KEY)).toEqual({name: 'new'}); + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual({name: 'new'}); }); it('should not read stale storage data when subscribing to individual RAM-only collection members', async () => { - const collectionMember = `${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + const collectionMember = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; // Simulate stale data in storage await StorageMock.setItem(collectionMember, {data: 'stale'}); Onyx.init({ - keys: RAM_ONLY_KEYS, - ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], }); await act(async () => waitForPromisesToResolve()); @@ -3277,12 +3266,12 @@ describe('RAM-only keys should not read from storage', () => { it('should still work correctly for normal keys when RAM-only keys have stale storage data', async () => { // Simulate both normal and RAM-only stale data in storage - await StorageMock.setItem(RAM_ONLY_KEYS.TEST_KEY, 'normal_value'); - await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'stale_ram_value'); + await StorageMock.setItem(ONYX_KEYS.TEST_KEY, 'normal_value'); + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_ram_value'); Onyx.init({ - keys: RAM_ONLY_KEYS, - ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], }); await act(async () => waitForPromisesToResolve()); @@ -3290,13 +3279,13 @@ describe('RAM-only keys should not read from storage', () => { let ramOnlyValue: unknown; const connection1 = Onyx.connect({ - key: RAM_ONLY_KEYS.TEST_KEY, + key: ONYX_KEYS.TEST_KEY, callback: (value) => { normalValue = value; }, }); const connection2 = Onyx.connect({ - key: RAM_ONLY_KEYS.RAM_ONLY_KEY, + key: ONYX_KEYS.RAM_ONLY_TEST_KEY, callback: (value) => { ramOnlyValue = value; }, @@ -3314,8 +3303,8 @@ describe('RAM-only keys should not read from storage', () => { it('should not sync RAM-only keys from other instances via keepInstancesSync', async () => { Onyx.init({ - keys: RAM_ONLY_KEYS, - ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], shouldSyncMultipleInstances: true, }); await act(async () => waitForPromisesToResolve()); @@ -3326,7 +3315,7 @@ describe('RAM-only keys should not read from storage', () => { let receivedValue: unknown; const connection = Onyx.connect({ - key: RAM_ONLY_KEYS.RAM_ONLY_KEY, + key: ONYX_KEYS.RAM_ONLY_TEST_KEY, callback: (value) => { receivedValue = value; }, @@ -3334,24 +3323,24 @@ describe('RAM-only keys should not read from storage', () => { await act(async () => waitForPromisesToResolve()); // Simulate another tab syncing a stale RAM-only key value - syncCallback(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'synced_stale_value'); + syncCallback(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'synced_stale_value'); await act(async () => waitForPromisesToResolve()); // The RAM-only key should NOT have been updated from the sync expect(receivedValue).toBeUndefined(); - expect(cache.get(RAM_ONLY_KEYS.RAM_ONLY_KEY)).toBeUndefined(); + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeUndefined(); // Verify that normal keys still sync correctly let normalValue: unknown; const connection2 = Onyx.connect({ - key: RAM_ONLY_KEYS.OTHER_TEST, + key: ONYX_KEYS.OTHER_TEST, callback: (value) => { normalValue = value; }, }); await act(async () => waitForPromisesToResolve()); - syncCallback(RAM_ONLY_KEYS.OTHER_TEST, 'synced_normal_value'); + syncCallback(ONYX_KEYS.OTHER_TEST, 'synced_normal_value'); await act(async () => waitForPromisesToResolve()); expect(normalValue).toEqual('synced_normal_value'); @@ -3361,16 +3350,16 @@ describe('RAM-only keys should not read from storage', () => { }); it('should serve RAM-only keys from cache and normal keys from storage in multiGet', async () => { - const ramOnlyMember = `${RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; - const normalMember = `${RAM_ONLY_KEYS.COLLECTION.TEST_KEY}1`; + const ramOnlyMember = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + const normalMember = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; // Pre-seed storage with stale data for both normal and RAM-only keys await StorageMock.setItem(normalMember, 'normal_from_storage'); await StorageMock.setItem(ramOnlyMember, {data: 'stale_collection_member'}); Onyx.init({ - keys: RAM_ONLY_KEYS, - ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], }); await act(async () => waitForPromisesToResolve()); @@ -3387,20 +3376,20 @@ describe('RAM-only keys should not read from storage', () => { }); it('should return cached value for RAM-only key after set then connect', async () => { - await StorageMock.setItem(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'stale_value'); + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_value'); Onyx.init({ - keys: RAM_ONLY_KEYS, - ramOnlyKeys: [RAM_ONLY_KEYS.RAM_ONLY_KEY, RAM_ONLY_KEYS.COLLECTION.RAM_ONLY_COLLECTION, RAM_ONLY_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], }); await act(async () => waitForPromisesToResolve()); // Write a fresh value to the RAM-only key - await Onyx.set(RAM_ONLY_KEYS.RAM_ONLY_KEY, 'fresh_value'); + await Onyx.set(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'fresh_value'); let receivedValue: unknown; const connection = Onyx.connect({ - key: RAM_ONLY_KEYS.RAM_ONLY_KEY, + key: ONYX_KEYS.RAM_ONLY_TEST_KEY, callback: (value) => { receivedValue = value; }, @@ -3409,10 +3398,10 @@ describe('RAM-only keys should not read from storage', () => { // Should get the fresh cached value, not the stale storage value expect(receivedValue).toEqual('fresh_value'); - expect(cache.get(RAM_ONLY_KEYS.RAM_ONLY_KEY)).toEqual('fresh_value'); + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual('fresh_value'); // Verify storage was NOT written to - const storageValue = await StorageMock.getItem(RAM_ONLY_KEYS.RAM_ONLY_KEY); + const storageValue = await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY); expect(storageValue).toEqual('stale_value'); Onyx.disconnect(connection); diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index 19fe2cd0..5ea9102a 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -16,13 +16,17 @@ const ONYXKEYS = { TEST_KEY: 'test_', TEST_KEY_2: 'test2_', EVICTABLE_TEST_KEY: 'evictable_test_', + RAM_ONLY_COLLECTION: 'ramOnlyCollection_', }, + RAM_ONLY_KEY: 'ramOnlyKey', + RAM_ONLY_WITH_INITIAL_VALUE: 'ramOnlyWithInitialValue', }; Onyx.init({ keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY], skippableCollectionMemberIDs: ['skippable-id'], + ramOnlyKeys: [ONYXKEYS.RAM_ONLY_KEY, ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYXKEYS.RAM_ONLY_WITH_INITIAL_VALUE], }); beforeEach(async () => { @@ -1171,6 +1175,123 @@ describe('useOnyx', () => { }); }); + describe('RAM-only keys', () => { + it('should not return stale storage data for a RAM-only key', async () => { + await StorageMock.setItem(ONYXKEYS.RAM_ONLY_KEY, 'stale_value'); + + const {result} = renderHook(() => useOnyx(ONYXKEYS.RAM_ONLY_KEY)); + + await act(async () => waitForPromisesToResolve()); + + expect(result.current[0]).toBeUndefined(); + expect(result.current[1].status).toEqual('loaded'); + }); + + it('should return value and loaded state after setting a RAM-only key', async () => { + const {result} = renderHook(() => useOnyx(ONYXKEYS.RAM_ONLY_KEY)); + + await act(async () => waitForPromisesToResolve()); + + expect(result.current[0]).toBeUndefined(); + expect(result.current[1].status).toEqual('loaded'); + + await act(async () => Onyx.set(ONYXKEYS.RAM_ONLY_KEY, 'fresh_value')); + + expect(result.current[0]).toEqual('fresh_value'); + expect(result.current[1].status).toEqual('loaded'); + }); + + it('should return updated value after merge on a RAM-only key', async () => { + await act(async () => Onyx.set(ONYXKEYS.RAM_ONLY_KEY, {name: 'test'})); + + const {result} = renderHook(() => useOnyx(ONYXKEYS.RAM_ONLY_KEY)); + + await act(async () => waitForPromisesToResolve()); + + expect(result.current[0]).toEqual({name: 'test'}); + expect(result.current[1].status).toEqual('loaded'); + + await act(async () => Onyx.merge(ONYXKEYS.RAM_ONLY_KEY, {age: 25})); + + expect(result.current[0]).toEqual({name: 'test', age: 25}); + expect(result.current[1].status).toEqual('loaded'); + }); + + it('should return collection data from cache for a RAM-only collection key', async () => { + await act(async () => + Onyx.mergeCollection(ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION, { + [`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}entry1`]: {id: '1'}, + [`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}entry2`]: {id: '2'}, + } as GenericCollection), + ); + + const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION)); + + await act(async () => waitForPromisesToResolve()); + + expect(result.current[0]).toEqual({ + [`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}entry1`]: {id: '1'}, + [`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}entry2`]: {id: '2'}, + }); + expect(result.current[1].status).toEqual('loaded'); + + await act(async () => Onyx.clear()); + + expect(result.current[0]).toBeUndefined(); + expect(result.current[1].status).toEqual('loaded'); + }); + + it('should return value for a RAM-only collection member after set', async () => { + const {result} = renderHook(() => useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}entry1`)); + + await act(async () => waitForPromisesToResolve()); + + expect(result.current[0]).toBeUndefined(); + expect(result.current[1].status).toEqual('loaded'); + + await act(async () => Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}entry1`, {id: 'fresh'})); + + expect(result.current[0]).toEqual({id: 'fresh'}); + expect(result.current[1].status).toEqual('loaded'); + }); + + it('should work with selector on a RAM-only key', async () => { + await act(async () => Onyx.set(ONYXKEYS.RAM_ONLY_KEY, {id: 'test_id', name: 'test_name'})); + + const {result} = renderHook(() => + useOnyx(ONYXKEYS.RAM_ONLY_KEY, { + selector: ((entry: OnyxEntry<{id: string; name: string}>) => entry?.id) as UseOnyxSelector, + }), + ); + + await act(async () => waitForPromisesToResolve()); + + expect(result.current[0]).toEqual('test_id'); + expect(result.current[1].status).toEqual('loaded'); + + await act(async () => Onyx.merge(ONYXKEYS.RAM_ONLY_KEY, {id: 'changed_id'})); + + expect(result.current[0]).toEqual('changed_id'); + expect(result.current[1].status).toEqual('loaded'); + }); + + it('should return `undefined` after clearing a RAM-only key', async () => { + await act(async () => Onyx.set(ONYXKEYS.RAM_ONLY_KEY, 'value')); + + const {result} = renderHook(() => useOnyx(ONYXKEYS.RAM_ONLY_KEY)); + + await act(async () => waitForPromisesToResolve()); + + expect(result.current[0]).toEqual('value'); + expect(result.current[1].status).toEqual('loaded'); + + await act(async () => Onyx.clear()); + + expect(result.current[0]).toBeUndefined(); + expect(result.current[1].status).toEqual('loaded'); + }); + }); + // This test suite must be the last one to avoid problems when running the other tests here. describe('canEvict', () => { const error = (key: string) => `canEvict can't be used on key '${key}'. This key must explicitly be flagged as safe for removal by adding it to Onyx.init({evictableKeys: []}).`; From b4f4b05f80c5fd114541e0e7418f27db50d3882f Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Mon, 23 Feb 2026 10:18:46 +0100 Subject: [PATCH 3/3] address review comments --- tests/unit/onyxTest.ts | 3 ++- tests/unit/useOnyxTest.ts | 5 ----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index 6cc027e6..0ed0751a 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -3118,6 +3118,7 @@ describe('RAM-only keys should not read from storage', () => { let cache: typeof OnyxCache; beforeEach(() => { + // Resets the deferred init task before each test. Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); cache = require('../../lib/OnyxCache').default; }); @@ -3176,7 +3177,7 @@ describe('RAM-only keys should not read from storage', () => { }); await act(async () => waitForPromisesToResolve()); - expect(receivedCollection!).toBeUndefined(); + expect(receivedCollection).toBeUndefined(); expect(cache.get(collectionMember1)).toBeUndefined(); expect(cache.get(collectionMember2)).toBeUndefined(); diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index 7598ae3c..95fff894 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -1081,11 +1081,6 @@ describe('useOnyx', () => { [`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}entry2`]: {id: '2'}, }); expect(result.current[1].status).toEqual('loaded'); - - await act(async () => Onyx.clear()); - - expect(result.current[0]).toBeUndefined(); - expect(result.current[1].status).toEqual('loaded'); }); it('should return value for a RAM-only collection member after set', async () => {