diff --git a/API.md b/API.md
index e766e551f..05fb217cc 100644
--- a/API.md
+++ b/API.md
@@ -58,6 +58,14 @@ value will be saved to storage after the default value.
Sets a collection by replacing all existing collection members with new values.
Any existing collection members not included in the new data will be removed.
+get(key) ⇒
+Reads the current value of an Onyx key once, without subscribing to it. Use useOnyx() or
+Onyx.connectWithoutView() when the value has to stay current.
+The result is the cached object itself rather than a copy, so treat it as read-only. A write that
+is still queued when get() is called is not visible to it, so await the write before reading.
+A collection with no members resolves to {}, while a collection read on an empty store resolves
+to undefined.
+
@@ -257,3 +265,27 @@ Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT, {
[`${ONYXKEYS.COLLECTION.REPORT}2`]: report2,
});
```
+
+
+## get(key) ⇒
+Reads the current value of an Onyx key once, without subscribing to it. Use `useOnyx()` or
+`Onyx.connectWithoutView()` when the value has to stay current.
+
+The result is the cached object itself rather than a copy, so treat it as read-only. A write that
+is still queued when `get()` is called is not visible to it, so await the write before reading.
+
+A collection with no members resolves to `{}`, while a collection read on an empty store resolves
+to `undefined`.
+
+**Kind**: global function
+**Returns**: The current value, or `undefined` if the key has none.
+
+| Param | Description |
+| --- | --- |
+| key | ONYXKEY to read, either a collection key or a single key |
+
+**Example**
+```js
+const report = await Onyx.get(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
+const allReports = await Onyx.get(ONYXKEYS.COLLECTION.REPORT);
+```
diff --git a/lib/Onyx.ts b/lib/Onyx.ts
index 29a1d20ab..d07a625a3 100644
--- a/lib/Onyx.ts
+++ b/lib/Onyx.ts
@@ -611,6 +611,46 @@ function setCollection(collectionKey: TKey, coll
return OnyxUtils.afterInit(() => OnyxUtils.setCollectionWithRetry({collectionKey, collection}));
}
+/**
+ * Reads the current value of an Onyx key once, without subscribing to it. Use `useOnyx()` or
+ * `Onyx.connectWithoutView()` when the value has to stay current.
+ *
+ * The result is the cached object itself rather than a copy, so treat it as read-only: mutating it would
+ * be seen by every other reader of that key. This is a convention rather than a type rule, because
+ * `useOnyx()` hands back that same object typed mutable and the two surfaces should not disagree. A write
+ * that is still queued when `get()` is called is not visible to it, so await the write before reading.
+ *
+ * A collection with no members resolves to `{}`, while a collection read on an empty store resolves
+ * to `undefined`.
+ *
+ * @example
+ * const report = await Onyx.get(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
+ * const allReports = await Onyx.get(ONYXKEYS.COLLECTION.REPORT);
+ *
+ * @param key ONYXKEY to read, either a collection key or a single key
+ * @returns The current value, or `undefined` if the key has none.
+ */
+function get(key: TKey): Promise> {
+ return OnyxUtils.afterInit(() => {
+ if (OnyxKeys.isCollectionKey(key)) {
+ const cachedCollection = OnyxUtils.tryGetCachedValue(key);
+
+ if (cachedCollection) {
+ return Promise.resolve(cachedCollection as OnyxValue);
+ }
+
+ // Only reached on a cold key index, since that is the one case tryGetCachedValue cannot answer.
+ return OnyxUtils.getAllKeys()
+ .then((allKeys) => OnyxUtils.multiGet([...allKeys].filter((memberKey) => OnyxKeys.isCollectionMemberKey(key, memberKey))))
+ .then(() => OnyxUtils.tryGetCachedValue(key) as OnyxValue);
+ }
+
+ // OnyxUtils.get is cache-first and already guards RAM-only keys. A key that storage has never
+ // held resolves to null there, which the public surface reports as undefined.
+ return OnyxUtils.get(key).then((value) => (value ?? undefined) as OnyxValue);
+ });
+}
+
const Onyx = {
METHOD: OnyxUtils.METHOD,
connect,
@@ -624,6 +664,7 @@ const Onyx = {
update,
clear,
init,
+ get,
registerLogger: Logger.registerLogger,
};
diff --git a/tests/perf-test/Onyx.perf-test.ts b/tests/perf-test/Onyx.perf-test.ts
index 196f728b6..221a043e6 100644
--- a/tests/perf-test/Onyx.perf-test.ts
+++ b/tests/perf-test/Onyx.perf-test.ts
@@ -98,6 +98,30 @@ describe('Onyx', () => {
});
});
+ describe('get', () => {
+ const COLLECTION_READS = 1000;
+
+ test('1k calls for the whole collection of 10k heavy objects', async () => {
+ await measureAsyncFunction(() => Promise.all(Array.from({length: COLLECTION_READS}, () => Onyx.get(collectionKey))), {
+ beforeEach: async () => {
+ await Onyx.multiSet(mockedReportActionsMap);
+ },
+ afterEach: clearOnyxAfterEachMeasure,
+ });
+ });
+
+ test('10k calls for individual collection members', async () => {
+ const memberKeys = Object.keys(mockedReportActionsMap);
+
+ await measureAsyncFunction(() => Promise.all(memberKeys.map((memberKey) => Onyx.get(memberKey))), {
+ beforeEach: async () => {
+ await Onyx.multiSet(mockedReportActionsMap);
+ },
+ afterEach: clearOnyxAfterEachMeasure,
+ });
+ });
+ });
+
describe('update', () => {
test('one call with 5k sets and 5k merges updates', async () => {
const changedReportActions = Object.fromEntries(Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const));
diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts
index 69f47c6b2..28ba69802 100644
--- a/tests/unit/onyxTest.ts
+++ b/tests/unit/onyxTest.ts
@@ -3238,6 +3238,224 @@ describe('Onyx', () => {
expect(cache.get(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE)).toEqual('default');
});
});
+
+ describe('get', () => {
+ it('should read a single key', async () => {
+ await Onyx.set(ONYX_KEYS.TEST_KEY, 'value');
+
+ await expect(Onyx.get(ONYX_KEYS.TEST_KEY)).resolves.toEqual('value');
+ });
+
+ it('should read a missing key as undefined', async () => {
+ await expect(Onyx.get(ONYX_KEYS.TEST_KEY)).resolves.toBeUndefined();
+ });
+
+ it('should resolve from cache without consulting storage when the value is already there', async () => {
+ await Onyx.set(ONYX_KEYS.TEST_KEY, {a: 1});
+
+ await StorageMock.removeItem(ONYX_KEYS.TEST_KEY);
+
+ await expect(Onyx.get(ONYX_KEYS.TEST_KEY)).resolves.toEqual({a: 1});
+ });
+
+ it('should resolve a collection from cache without consulting storage', async () => {
+ await Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {
+ [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {id: 1},
+ } as GenericCollection);
+
+ await StorageMock.removeItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`);
+
+ await expect(Onyx.get(ONYX_KEYS.COLLECTION.TEST_KEY)).resolves.toEqual({
+ [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {id: 1},
+ });
+ });
+
+ it('should fall back to storage when the key is not in cache', async () => {
+ await Onyx.set(ONYX_KEYS.TEST_KEY, {a: 1});
+
+ // Value out of cache, storage intact.
+ cache.drop(ONYX_KEYS.TEST_KEY);
+ cache.clearNullishStorageKeys();
+ expect(cache.hasCacheForKey(ONYX_KEYS.TEST_KEY)).toBe(false);
+
+ await expect(Onyx.get(ONYX_KEYS.TEST_KEY)).resolves.toEqual({a: 1});
+ });
+
+ it('should read a whole collection assembled from its member keys', async () => {
+ await Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {
+ [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {id: 1},
+ [`${ONYX_KEYS.COLLECTION.TEST_KEY}2`]: {id: 2},
+ } as GenericCollection);
+
+ await expect(Onyx.get(ONYX_KEYS.COLLECTION.TEST_KEY)).resolves.toEqual({
+ [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {id: 1},
+ [`${ONYX_KEYS.COLLECTION.TEST_KEY}2`]: {id: 2},
+ });
+ });
+
+ it('should fall back to storage for a collection the cache holds nothing for', async () => {
+ const memberKeys = [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, `${ONYX_KEYS.COLLECTION.TEST_KEY}2`];
+ await Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {
+ [memberKeys[0]]: {id: 1},
+ [memberKeys[1]]: {id: 2},
+ } as GenericCollection);
+
+ // Cache and key index empty, storage intact: what a failed hydration leaves.
+ for (const memberKey of memberKeys) {
+ cache.drop(memberKey);
+ }
+ cache.clearNullishStorageKeys();
+ cache.setAllKeys([]);
+
+ await expect(Onyx.get(ONYX_KEYS.COLLECTION.TEST_KEY)).resolves.toEqual({
+ [memberKeys[0]]: {id: 1},
+ [memberKeys[1]]: {id: 2},
+ });
+ });
+
+ it('should fall back to storage for a collection member read by its own key', async () => {
+ const memberKey = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`;
+ await Onyx.set(memberKey, {id: 1});
+
+ cache.drop(memberKey);
+ cache.clearNullishStorageKeys();
+
+ await expect(Onyx.get(memberKey)).resolves.toEqual({id: 1});
+ });
+
+ it('should warm the cache with what it fell back to storage for', async () => {
+ await Onyx.set(ONYX_KEYS.TEST_KEY, {a: 1});
+ cache.drop(ONYX_KEYS.TEST_KEY);
+ cache.clearNullishStorageKeys();
+
+ await expect(Onyx.get(ONYX_KEYS.TEST_KEY)).resolves.toEqual({a: 1});
+
+ await StorageMock.removeItem(ONYX_KEYS.TEST_KEY);
+ await expect(Onyx.get(ONYX_KEYS.TEST_KEY)).resolves.toEqual({a: 1});
+ });
+
+ it('should not read stale storage data for a RAM-only key', async () => {
+ await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, {stale: true});
+ cache.drop(ONYX_KEYS.RAM_ONLY_TEST_KEY);
+ cache.clearNullishStorageKeys();
+
+ await expect(Onyx.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).resolves.toBeUndefined();
+ });
+
+ it('should not read stale storage data for a member of a RAM-only collection', async () => {
+ const memberKey = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`;
+ await StorageMock.setItem(memberKey, {stale: true});
+ cache.drop(memberKey);
+ cache.clearNullishStorageKeys();
+
+ await expect(Onyx.get(memberKey)).resolves.toBeUndefined();
+
+ await expect(Onyx.get(ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION)).resolves.toEqual({});
+ });
+
+ it('should read a collection with no members as an empty object once the store holds data', async () => {
+ await Onyx.set(ONYX_KEYS.TEST_KEY, 'value');
+
+ // One key loaded means hydration ran, so "no members" is empty rather than unloaded.
+ await expect(Onyx.get(ONYX_KEYS.COLLECTION.ANIMALS)).resolves.toEqual({});
+ });
+
+ it('should return the same value useOnyx would read for the same key', async () => {
+ await Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {
+ [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {id: 1},
+ } as GenericCollection);
+
+ let fromSubscription: unknown;
+ connection = Onyx.connectWithoutView({
+ key: ONYX_KEYS.COLLECTION.TEST_KEY,
+ callback: (value) => {
+ fromSubscription = value;
+ },
+ });
+ await waitForPromisesToResolve();
+
+ expect(await Onyx.get(ONYX_KEYS.COLLECTION.TEST_KEY)).toEqual(fromSubscription);
+ });
+
+ it('should agree with a subscription on a key the cache no longer holds', async () => {
+ await Onyx.set(ONYX_KEYS.TEST_KEY, {a: 1});
+ const allKeys = [...cache.getAllKeys()];
+
+ // Keep the key index: it is what tells a subscriber the key exists.
+ const evict = () => {
+ cache.drop(ONYX_KEYS.TEST_KEY);
+ cache.clearNullishStorageKeys();
+ cache.setAllKeys(allKeys);
+ };
+
+ evict();
+ const fromGet = await Onyx.get(ONYX_KEYS.TEST_KEY);
+
+ evict();
+ let fromSubscription: unknown;
+ connection = Onyx.connectWithoutView({
+ key: ONYX_KEYS.TEST_KEY,
+ callback: (value) => {
+ fromSubscription = value;
+ },
+ });
+ await waitForPromisesToResolve();
+
+ expect(fromGet).toEqual({a: 1});
+ expect(fromGet).toEqual(fromSubscription);
+ });
+
+ it('should not subscribe to the key it reads', async () => {
+ await Onyx.set(ONYX_KEYS.TEST_KEY, 'first');
+ await Onyx.get(ONYX_KEYS.TEST_KEY);
+
+ const sendDataToConnectionSpy = jest.spyOn(OnyxUtils, 'sendDataToConnection');
+ await Onyx.set(ONYX_KEYS.TEST_KEY, 'second');
+
+ expect(sendDataToConnectionSpy).not.toHaveBeenCalled();
+ await expect(Onyx.get(ONYX_KEYS.TEST_KEY)).resolves.toEqual('second');
+
+ sendDataToConnectionSpy.mockRestore();
+ });
+
+ it('should not see an un-awaited merge to the same key', async () => {
+ await Onyx.set(ONYX_KEYS.TEST_KEY, {a: 1});
+
+ // merge() batches past the microtask an await yields, so awaiting the read does not fix
+ // read-after-write. Await the write, or read first.
+ const mergePromise = Onyx.merge(ONYX_KEYS.TEST_KEY, {b: 2});
+ await expect(Onyx.get(ONYX_KEYS.TEST_KEY)).resolves.toEqual({a: 1});
+
+ await mergePromise;
+ await expect(Onyx.get(ONYX_KEYS.TEST_KEY)).resolves.toEqual({a: 1, b: 2});
+ });
+
+ it('should see an awaited set to the same key', async () => {
+ await Onyx.set(ONYX_KEYS.TEST_KEY, {a: 1});
+
+ await expect(Onyx.get(ONYX_KEYS.TEST_KEY)).resolves.toEqual({a: 1});
+ });
+
+ it('should resolve a single key to the live cached object, not a copy', async () => {
+ await Onyx.set(ONYX_KEYS.TEST_KEY, {a: 1});
+
+ const value = await Onyx.get(ONYX_KEYS.TEST_KEY);
+
+ // Pinned so adding a defensive copy has to be a deliberate change.
+ expect(value).toBe(cache.get(ONYX_KEYS.TEST_KEY));
+ });
+
+ it('should resolve a collection to a frozen object', async () => {
+ await Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {
+ [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {id: 1},
+ } as GenericCollection);
+
+ const collection = await Onyx.get(ONYX_KEYS.COLLECTION.TEST_KEY);
+
+ // A collection is shared and frozen, so the same mutation throws.
+ expect(Object.isFrozen(collection)).toBe(true);
+ });
+ });
});
// Separate describe block for Onyx.init to control initialization during each test.
@@ -3342,6 +3560,65 @@ describe('Onyx.init', () => {
expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toEqual('test_1');
});
+
+ // Pre-init state is asserted after init: asserting before it leaves the shared afterEach on an
+ // Onyx.clear() that never resolves, turning any failure into a 60s timeout.
+ it('get waits for initialization and then resolves with the hydrated value', async () => {
+ await StorageMock.setItem(ONYX_KEYS.TEST_KEY, 'from-storage');
+
+ let resolvedValue: unknown = 'not-resolved';
+ Onyx.get(ONYX_KEYS.TEST_KEY).then((value) => {
+ resolvedValue = value;
+ });
+ await act(async () => waitForPromisesToResolve());
+ const valueBeforeInit = resolvedValue;
+
+ Onyx.init({keys: ONYX_KEYS});
+ await act(async () => waitForPromisesToResolve());
+
+ expect(valueBeforeInit).toBe('not-resolved');
+ expect(resolvedValue).toBe('from-storage');
+ });
+
+ it('get resolves undefined after initialization when the key has no value', async () => {
+ let resolvedValue: unknown = 'not-resolved';
+ Onyx.get(ONYX_KEYS.TEST_KEY).then((value) => {
+ resolvedValue = value;
+ });
+ await act(async () => waitForPromisesToResolve());
+ const valueBeforeInit = resolvedValue;
+
+ Onyx.init({keys: ONYX_KEYS});
+ await act(async () => waitForPromisesToResolve());
+
+ expect(valueBeforeInit).toBe('not-resolved');
+ expect(resolvedValue).toBeUndefined();
+ });
+
+ it('get resolves a collection as undefined on a completely empty store', async () => {
+ // No stored keys means no index, and an absent index cannot tell empty from unloaded.
+ Onyx.init({keys: ONYX_KEYS});
+ await act(async () => waitForPromisesToResolve());
+
+ await expect(Onyx.get(ONYX_KEYS.COLLECTION.TEST_KEY)).resolves.toBeUndefined();
+ });
+
+ it('get waits for initialization before resolving a collection', async () => {
+ await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, {id: 1});
+
+ let resolvedValue: unknown = 'not-resolved';
+ Onyx.get(ONYX_KEYS.COLLECTION.TEST_KEY).then((value) => {
+ resolvedValue = value;
+ });
+ await act(async () => waitForPromisesToResolve());
+ const valueBeforeInit = resolvedValue;
+
+ Onyx.init({keys: ONYX_KEYS});
+ await act(async () => waitForPromisesToResolve());
+
+ expect(valueBeforeInit).toBe('not-resolved');
+ expect(resolvedValue).toEqual({[`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {id: 1}});
+ });
});
});