diff --git a/API-INTERNAL.md b/API-INTERNAL.md
index 1f7afb68..10b70448 100644
--- a/API-INTERNAL.md
+++ b/API-INTERNAL.md
@@ -145,6 +145,9 @@ that this internal function allows passing an additional mergeReplaceNullP
Any existing collection members not included in the new data will not be removed.
Retries on failure.
+getCallbackToStateMapping()
+Getter - returns the callback to state mapping, useful in test environments.
+
clearOnyxUtilsInternals()
Clear internal variables used in this file, useful in test environments.
@@ -519,6 +522,12 @@ Retries on failure.
| params.collection | Object collection keyed by individual collection member keys and values |
| retryAttempt | retry attempt |
+
+
+## getCallbackToStateMapping()
+Getter - returns the callback to state mapping, useful in test environments.
+
+**Kind**: global function
## clearOnyxUtilsInternals()
diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts
index 46bdaac4..da3d1e1e 100644
--- a/lib/OnyxUtils.ts
+++ b/lib/OnyxUtils.ts
@@ -1055,6 +1055,24 @@ function subscribeToKey(connectOptions: ConnectOptions;
callbackToStateMapping[subscriptionID].subscriptionID = subscriptionID;
+ // If the subscriber is attempting to connect to a collection member whose ID is skippable (e.g. "undefined", "null", etc.)
+ // we suppress wiring the subscription fully to avoid unnecessary callback emissions such as for "report_undefined".
+ // We still return a valid subscriptionID so callers can disconnect safely.
+ try {
+ const skippableIDs = getSkippableCollectionMemberIDs();
+ if (skippableIDs.size) {
+ const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(mapping.key);
+ if (skippableIDs.has(collectionMemberID)) {
+ // Clean up the provisional mapping to avoid retaining unused subscribers.
+ cache.addNullishStorageKey(mapping.key);
+ delete callbackToStateMapping[subscriptionID];
+ return subscriptionID;
+ }
+ }
+ } catch (e) {
+ // Not a collection member key, proceed as usual.
+ }
+
// When keyChanged is called, a key is passed and the method looks through all the Subscribers in callbackToStateMapping for the matching key to get the subscriptionID
// to avoid having to loop through all the Subscribers all the time (even when just one connection belongs to one key),
// We create a mapping from key to lists of subscriptionIDs to access the specific list of subscriptionIDs.
@@ -1661,6 +1679,13 @@ function logKeyRemoved(onyxMethod: Extract, key: On
Logger.logInfo(`${onyxMethod} called for key: ${key} => null passed, so key was removed`);
}
+/**
+ * Getter - returns the callback to state mapping, useful in test environments.
+ */
+function getCallbackToStateMapping(): Record> {
+ return callbackToStateMapping;
+}
+
/**
* Clear internal variables used in this file, useful in test environments.
*/
@@ -1720,6 +1745,7 @@ const OnyxUtils = {
setWithRetry,
multiSetWithRetry,
setCollectionWithRetry,
+ getCallbackToStateMapping,
};
export type {OnyxMethod};
diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts
index f124639b..1861090b 100644
--- a/tests/unit/onyxUtilsTest.ts
+++ b/tests/unit/onyxUtilsTest.ts
@@ -95,6 +95,74 @@ describe('OnyxUtils', () => {
afterEach(() => jest.clearAllMocks());
+ describe('skippable member subscriptions', () => {
+ const BASE = ONYXKEYS.COLLECTION.TEST_KEY;
+
+ beforeEach(() => {
+ // Enable skipping of undefined member IDs for these tests
+ OnyxUtils.setSkippableCollectionMemberIDs(new Set(['undefined']));
+ });
+
+ afterEach(() => {
+ // Restore to no skippable IDs to avoid affecting other tests
+ OnyxUtils.setSkippableCollectionMemberIDs(new Set());
+ });
+
+ it('does not emit initial callback for report_undefined member', async () => {
+ const key = `${BASE}undefined`;
+ const callback = jest.fn();
+ Onyx.connect({key, callback});
+
+ // Flush async subscription flow
+ await act(async () => waitForPromisesToResolve());
+
+ // No initial data should be sent for a skippable member
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('still emits for valid member keys', async () => {
+ const key = `${BASE}123`;
+ await Onyx.set(key, {id: 123});
+
+ const callback = jest.fn();
+ Onyx.connect({key, callback});
+ await act(async () => waitForPromisesToResolve());
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith({id: 123}, key);
+ });
+
+ it('omits skippable members from base collection', async () => {
+ const undefinedKey = `${BASE}undefined`;
+ const validKey = `${BASE}1`;
+
+ await Onyx.set(undefinedKey, {bad: true});
+ await Onyx.set(validKey, {ok: true});
+
+ let received: Record | undefined;
+ Onyx.connect({
+ key: BASE,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ received = value as Record;
+ },
+ });
+ await act(async () => waitForPromisesToResolve());
+ expect(received).toEqual({[validKey]: {ok: true}});
+ expect(Object.keys(received ?? {})).not.toContain(undefinedKey);
+ });
+
+ it('does not register an active subscription in callbackToStateMapping for a skippable member', async () => {
+ const skippableKey = `${BASE}undefined`;
+ Onyx.connect({key: skippableKey, callback: jest.fn()});
+
+ await act(async () => waitForPromisesToResolve());
+
+ const mappings = OnyxUtils.getCallbackToStateMapping();
+ const hasActiveSubscription = Object.values(mappings).some((m) => m.key === skippableKey);
+ expect(hasActiveSubscription).toBe(false);
+ });
+ });
+
describe('partialSetCollection', () => {
beforeEach(() => {
Onyx.clear();
diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts
index 5dddb1d1..b4baa1f8 100644
--- a/tests/unit/useOnyxTest.ts
+++ b/tests/unit/useOnyxTest.ts
@@ -1110,6 +1110,54 @@ describe('useOnyx', () => {
expect(result.current[0]).toBeUndefined();
expect(result.current[1].status).toEqual('loaded');
});
+
+ it('should return undefined and loaded state when switching from a valid key to a skippable one', async () => {
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, {id: '1'});
+ // Seed a value directly in storage for the skippable key.
+ // If the subscription is NOT skipped, Onyx would load this and return it.
+ // Asserting undefined below proves the subscription was actually suppressed.
+ await StorageMock.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`, {id: 'skippable'});
+
+ const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1` as string});
+
+ await act(async () => waitForPromisesToResolve());
+
+ expect(result.current[0]).toEqual({id: '1'});
+ expect(result.current[1].status).toEqual('loaded');
+
+ await act(async () => {
+ rerender(`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`);
+ });
+
+ await act(async () => waitForPromisesToResolve());
+
+ expect(result.current[0]).toBeUndefined();
+ expect(result.current[1].status).toEqual('loaded');
+ });
+
+ it('should transition through loading and return value when switching from a skippable key to a valid one', async () => {
+ // Seed a value for the skippable key — must stay invisible to the hook
+ await StorageMock.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`, {id: 'skippable'});
+ // Seed the target valid key in storage only (not in cache) so the switch goes through loading
+ await StorageMock.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, {id: '1'});
+
+ const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id` as string});
+
+ await act(async () => waitForPromisesToResolve());
+
+ expect(result.current[0]).toBeUndefined();
+ expect(result.current[1].status).toEqual('loaded');
+
+ // Switch to a valid key whose value is in storage but not in cache — should transition through loading
+ rerender(`${ONYXKEYS.COLLECTION.TEST_KEY}1`);
+
+ expect(result.current[1].status).toEqual('loading');
+
+ await act(async () => waitForPromisesToResolve());
+
+ expect(result.current[0]).toEqual({id: '1'});
+ expect(result.current[1].status).toEqual('loaded');
+ });
});
describe('RAM-only keys', () => {