diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 39e8afd0..f5f26111 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1064,6 +1064,26 @@ 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] = splitCollectionMemberKey(mapping.key); + if (skippableIDs.has(collectionMemberID)) { + // Clean up the provisional mapping to avoid retaining unused subscribers + // Mark this key as known-nullish and present in cache so hooks see a loaded undefined state + cache.addNullishStorageKey(mapping.key); + cache.set(mapping.key, undefined as OnyxValue); + 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. diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 016eb0f3..196df651 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -229,6 +229,24 @@ function useOnyx>( }, [key, options?.canEvict]); const getSnapshot = useCallback(() => { + // Fast path: if subscribing to a skippable collection member id, return undefined as loaded immediately + if (isFirstConnectionRef.current) { + try { + const [, memberId] = OnyxUtils.splitCollectionMemberKey(key); + if (OnyxUtils.getSkippableCollectionMemberIDs().has(memberId)) { + // Finalize initial state as loaded undefined and stop further first-connection flows + if (resultRef.current[1].status !== 'loaded' || resultRef.current[0] !== undefined) { + resultRef.current = [undefined, {status: 'loaded'}]; + onyxSnapshotCache.setCachedResult>(key, cacheKey, resultRef.current); + } + isFirstConnectionRef.current = false; + shouldGetCachedValueRef.current = false; + return resultRef.current; + } + } catch (e) { + // Not a collection member, continue as usual + } + } // Check if we have any cache for this Onyx key // Don't use cache for first connection with initWithStoredValues: false // Also don't use cache during active data updates (when shouldGetCachedValueRef is true) diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 9b120a58..60d0347e 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -1,9 +1,11 @@ +import {act} from '@testing-library/react-native'; import Onyx from '../../lib'; import OnyxUtils from '../../lib/OnyxUtils'; import type {GenericDeepRecord} from '../types'; import utils from '../../lib/utils'; import type {Collection, OnyxCollection} from '../../lib/types'; import type GenericCollection from '../utils/GenericCollection'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; const testObject: GenericDeepRecord = { a: 'a', @@ -83,6 +85,62 @@ Onyx.init({ beforeEach(() => Onyx.clear()); describe('OnyxUtils', () => { + 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); + }); + }); describe('splitCollectionMemberKey', () => { describe('should return correct values', () => { const dataResult: Record = {