Skip to content

Commit d5caa29

Browse files
committed
fix: adding a guard that skips subscriptions for invalid keys
1 parent bcfee2c commit d5caa29

4 files changed

Lines changed: 151 additions & 0 deletions

File tree

API-INTERNAL.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ that this internal function allows passing an additional <code>mergeReplaceNullP
145145
Any existing collection members not included in the new data will not be removed.
146146
Retries on failure.</p>
147147
</dd>
148+
<dt><a href="#getCallbackToStateMapping">getCallbackToStateMapping()</a></dt>
149+
<dd><p>Getter - returns the callback to state mapping, useful in test environments.</p>
150+
</dd>
148151
<dt><a href="#clearOnyxUtilsInternals">clearOnyxUtilsInternals()</a></dt>
149152
<dd><p>Clear internal variables used in this file, useful in test environments.</p>
150153
</dd>
@@ -519,6 +522,12 @@ Retries on failure.
519522
| params.collection | Object collection keyed by individual collection member keys and values |
520523
| retryAttempt | retry attempt |
521524

525+
<a name="getCallbackToStateMapping"></a>
526+
527+
## getCallbackToStateMapping()
528+
Getter - returns the callback to state mapping, useful in test environments.
529+
530+
**Kind**: global function
522531
<a name="clearOnyxUtilsInternals"></a>
523532

524533
## clearOnyxUtilsInternals()

lib/OnyxUtils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,24 @@ function subscribeToKey<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKe
10551055
callbackToStateMapping[subscriptionID] = mapping as CallbackToStateMapping<OnyxKey>;
10561056
callbackToStateMapping[subscriptionID].subscriptionID = subscriptionID;
10571057

1058+
// If the subscriber is attempting to connect to a collection member whose ID is skippable (e.g. "undefined", "null", etc.)
1059+
// we suppress wiring the subscription fully to avoid unnecessary callback emissions such as for "report_undefined".
1060+
// We still return a valid subscriptionID so callers can disconnect safely.
1061+
try {
1062+
const skippableIDs = getSkippableCollectionMemberIDs();
1063+
if (skippableIDs.size) {
1064+
const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(mapping.key);
1065+
if (skippableIDs.has(collectionMemberID)) {
1066+
// Clean up the provisional mapping to avoid retaining unused subscribers.
1067+
cache.addNullishStorageKey(mapping.key);
1068+
delete callbackToStateMapping[subscriptionID];
1069+
return subscriptionID;
1070+
}
1071+
}
1072+
} catch (e) {
1073+
// Not a collection member key, proceed as usual.
1074+
}
1075+
10581076
// 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
10591077
// to avoid having to loop through all the Subscribers all the time (even when just one connection belongs to one key),
10601078
// 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<OnyxMethod, 'set' | 'merge'>, key: On
16611679
Logger.logInfo(`${onyxMethod} called for key: ${key} => null passed, so key was removed`);
16621680
}
16631681

1682+
/**
1683+
* Getter - returns the callback to state mapping, useful in test environments.
1684+
*/
1685+
function getCallbackToStateMapping(): Record<number, CallbackToStateMapping<OnyxKey>> {
1686+
return callbackToStateMapping;
1687+
}
1688+
16641689
/**
16651690
* Clear internal variables used in this file, useful in test environments.
16661691
*/
@@ -1720,6 +1745,7 @@ const OnyxUtils = {
17201745
setWithRetry,
17211746
multiSetWithRetry,
17221747
setCollectionWithRetry,
1748+
getCallbackToStateMapping,
17231749
};
17241750

17251751
export type {OnyxMethod};

tests/unit/onyxUtilsTest.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,74 @@ describe('OnyxUtils', () => {
9595

9696
afterEach(() => jest.clearAllMocks());
9797

98+
describe('skippable member subscriptions', () => {
99+
const BASE = ONYXKEYS.COLLECTION.TEST_KEY;
100+
101+
beforeEach(() => {
102+
// Enable skipping of undefined member IDs for these tests
103+
OnyxUtils.setSkippableCollectionMemberIDs(new Set(['undefined']));
104+
});
105+
106+
afterEach(() => {
107+
// Restore to no skippable IDs to avoid affecting other tests
108+
OnyxUtils.setSkippableCollectionMemberIDs(new Set());
109+
});
110+
111+
it('does not emit initial callback for report_undefined member', async () => {
112+
const key = `${BASE}undefined`;
113+
const callback = jest.fn();
114+
Onyx.connect({key, callback});
115+
116+
// Flush async subscription flow
117+
await act(async () => waitForPromisesToResolve());
118+
119+
// No initial data should be sent for a skippable member
120+
expect(callback).not.toHaveBeenCalled();
121+
});
122+
123+
it('still emits for valid member keys', async () => {
124+
const key = `${BASE}123`;
125+
await Onyx.set(key, {id: 123});
126+
127+
const callback = jest.fn();
128+
Onyx.connect({key, callback});
129+
await act(async () => waitForPromisesToResolve());
130+
expect(callback).toHaveBeenCalledTimes(1);
131+
expect(callback).toHaveBeenCalledWith({id: 123}, key);
132+
});
133+
134+
it('omits skippable members from base collection', async () => {
135+
const undefinedKey = `${BASE}undefined`;
136+
const validKey = `${BASE}1`;
137+
138+
await Onyx.set(undefinedKey, {bad: true});
139+
await Onyx.set(validKey, {ok: true});
140+
141+
let received: Record<string, unknown> | undefined;
142+
Onyx.connect({
143+
key: BASE,
144+
waitForCollectionCallback: true,
145+
callback: (value) => {
146+
received = value as Record<string, unknown>;
147+
},
148+
});
149+
await act(async () => waitForPromisesToResolve());
150+
expect(received).toEqual({[validKey]: {ok: true}});
151+
expect(Object.keys(received ?? {})).not.toContain(undefinedKey);
152+
});
153+
154+
it('does not register an active subscription in callbackToStateMapping for a skippable member', async () => {
155+
const skippableKey = `${BASE}undefined`;
156+
Onyx.connect({key: skippableKey, callback: jest.fn()});
157+
158+
await act(async () => waitForPromisesToResolve());
159+
160+
const mappings = OnyxUtils.getCallbackToStateMapping();
161+
const hasActiveSubscription = Object.values(mappings).some((m) => m.key === skippableKey);
162+
expect(hasActiveSubscription).toBe(false);
163+
});
164+
});
165+
98166
describe('partialSetCollection', () => {
99167
beforeEach(() => {
100168
Onyx.clear();

tests/unit/useOnyxTest.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,6 +1110,54 @@ describe('useOnyx', () => {
11101110
expect(result.current[0]).toBeUndefined();
11111111
expect(result.current[1].status).toEqual('loaded');
11121112
});
1113+
1114+
it('should return undefined and loaded state when switching from a valid key to a skippable one', async () => {
1115+
await Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, {id: '1'});
1116+
// Seed a value directly in storage for the skippable key.
1117+
// If the subscription is NOT skipped, Onyx would load this and return it.
1118+
// Asserting undefined below proves the subscription was actually suppressed.
1119+
await StorageMock.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`, {id: 'skippable'});
1120+
1121+
const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1` as string});
1122+
1123+
await act(async () => waitForPromisesToResolve());
1124+
1125+
expect(result.current[0]).toEqual({id: '1'});
1126+
expect(result.current[1].status).toEqual('loaded');
1127+
1128+
await act(async () => {
1129+
rerender(`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`);
1130+
});
1131+
1132+
await act(async () => waitForPromisesToResolve());
1133+
1134+
expect(result.current[0]).toBeUndefined();
1135+
expect(result.current[1].status).toEqual('loaded');
1136+
});
1137+
1138+
it('should transition through loading and return value when switching from a skippable key to a valid one', async () => {
1139+
// Seed a value for the skippable key — must stay invisible to the hook
1140+
await StorageMock.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`, {id: 'skippable'});
1141+
// Seed the target valid key in storage only (not in cache) so the switch goes through loading
1142+
await StorageMock.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, {id: '1'});
1143+
1144+
const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id` as string});
1145+
1146+
await act(async () => waitForPromisesToResolve());
1147+
1148+
expect(result.current[0]).toBeUndefined();
1149+
expect(result.current[1].status).toEqual('loaded');
1150+
1151+
// Switch to a valid key whose value is in storage but not in cache — should transition through loading
1152+
rerender(`${ONYXKEYS.COLLECTION.TEST_KEY}1`);
1153+
1154+
expect(result.current[1].status).toEqual('loading');
1155+
1156+
await act(async () => waitForPromisesToResolve());
1157+
1158+
expect(result.current[0]).toEqual({id: '1'});
1159+
expect(result.current[1].status).toEqual('loaded');
1160+
});
11131161
});
11141162

11151163
describe('RAM-only keys', () => {

0 commit comments

Comments
 (0)