Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions lib/OnyxUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,26 @@ function subscribeToKey<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKe
callbackToStateMapping[subscriptionID] = mapping as CallbackToStateMapping<OnyxKey>;
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<TKey>);
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.
Expand Down
18 changes: 18 additions & 0 deletions lib/useOnyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,24 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
}, [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<UseOnyxResult<TReturnValue>>(key, cacheKey, resultRef.current);
}
isFirstConnectionRef.current = false;
shouldGetCachedValueRef.current = false;
return resultRef.current;
}
} catch (e) {
// Not a collection member, continue as usual
}
}
Comment on lines +232 to +249
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid this? OnyxUtils.splitCollectionMemberKey isn't that cheap and getSnapshot is already pretty much heavy with lots of other logics.

// 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)
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/onyxUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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<string, unknown> | undefined;
Onyx.connect({
key: BASE,
waitForCollectionCallback: true,
callback: (value) => {
received = value as Record<string, unknown>;
},
});
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<string, [string, string]> = {
Expand Down
Loading