Skip to content

Commit 75efda5

Browse files
committed
Merge branch 'main' into feature/remove-initWithStoredValues
2 parents d83e8b7 + 86ce519 commit 75efda5

4 files changed

Lines changed: 308 additions & 8 deletions

File tree

lib/OnyxUtils.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,16 @@ function keysChanged<TKey extends CollectionKeyBase>(
550550
const previousCollection = partialPreviousCollection ?? {};
551551
const changedMemberKeys = Object.keys(partialCollection ?? {});
552552

553+
// Add or remove the keys from the recentlyAccessedKeys list
554+
for (const memberKey of changedMemberKeys) {
555+
const value = partialCollection?.[memberKey];
556+
if (value !== null && value !== undefined) {
557+
cache.addLastAccessedKey(memberKey, false);
558+
} else {
559+
cache.removeLastAccessedKey(memberKey);
560+
}
561+
}
562+
553563
// Use indexed lookup instead of scanning all subscribers.
554564
// We need subscribers for: (1) the collection key itself, and (2) individual changed member keys.
555565
const collectionSubscriberIDs = onyxKeyToSubscriptionIDs.get(collectionKey) ?? [];
@@ -578,12 +588,20 @@ function keysChanged<TKey extends CollectionKeyBase>(
578588
continue;
579589
}
580590

581-
// Not using waitForCollectionCallback — notify per changed key
591+
// Not using waitForCollectionCallback — notify per changed key.
592+
// Re-check the subscription on each iteration because the callback may
593+
// synchronously disconnect itself (removing it from callbackToStateMapping),
594+
// in which case we must stop firing further callbacks for this subscriber.
582595
for (const dataKey of changedMemberKeys) {
596+
const currentSubscriber = callbackToStateMapping[subID];
597+
if (!currentSubscriber || typeof currentSubscriber.callback !== 'function') {
598+
break;
599+
}
583600
if (cachedCollection[dataKey] === previousCollection[dataKey]) {
584601
continue;
585602
}
586-
subscriber.callback(cachedCollection[dataKey], dataKey);
603+
const currentSubscriberCallback = currentSubscriber.callback as DefaultConnectCallback<TKey>;
604+
currentSubscriberCallback(cachedCollection[dataKey], dataKey as TKey);
587605
}
588606
} catch (error) {
589607
Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`);
@@ -1348,16 +1366,46 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
13481366

13491367
const keyValuePairsToSet = OnyxUtils.prepareKeyValuePairsForStorage(newData, true);
13501368

1369+
// Group collection members by their parent collection key so each collection can be notified
1370+
// via a single batched keysChanged() call instead of one keyChanged() per member. For each
1371+
// collection, `partial` holds the new values being set and `previous` holds the cached values
1372+
// from before the set, which keysChanged() uses to skip subscribers whose value didn't change.
1373+
const collectionBatches = new Map<string, {partial: Record<string, OnyxValue<OnyxKey>>; previous: Record<string, OnyxValue<OnyxKey>>}>();
1374+
13511375
for (const [key, value] of keyValuePairsToSet) {
13521376
// When we use multiSet to set a key we want to clear the current delta changes from Onyx.merge that were queued
13531377
// before the value was set. If Onyx.merge is currently reading the old value from storage, it will then not apply the changes.
13541378
if (OnyxUtils.hasPendingMergeForKey(key)) {
13551379
delete OnyxUtils.getMergeQueue()[key];
13561380
}
13571381

1358-
// Update cache and optimistically inform subscribers
1359-
cache.set(key, value);
1360-
keyChanged(key, value);
1382+
const collectionKey = OnyxKeys.getCollectionKey(key);
1383+
if (collectionKey && OnyxKeys.isCollectionMemberKey(collectionKey, key)) {
1384+
// Capture the previous cached value BEFORE calling cache.set() so keysChanged()
1385+
// can diff old vs new per-member.
1386+
const previousValue = cache.get(key);
1387+
cache.set(key, value);
1388+
1389+
let batch = collectionBatches.get(collectionKey);
1390+
if (!batch) {
1391+
batch = {partial: {}, previous: {}};
1392+
collectionBatches.set(collectionKey, batch);
1393+
}
1394+
batch.partial[key] = value;
1395+
batch.previous[key] = previousValue;
1396+
} else {
1397+
// Non-collection keys are notified inline (cache.set + keyChanged in iteration order)
1398+
// so re-entrant callbacks (e.g. Onyx.set inside a callback) see consistent cache
1399+
// and subscriber state, matching the original per-key notification semantics.
1400+
cache.set(key, value);
1401+
keyChanged(key, value);
1402+
}
1403+
}
1404+
1405+
// One keysChanged() per collection — fires each collection-level subscriber once and lets
1406+
// keysChanged() internally decide which individual member subscribers need notification.
1407+
for (const [collectionKey, batch] of collectionBatches) {
1408+
keysChanged(collectionKey as CollectionKeyBase, batch.partial, batch.previous);
13611409
}
13621410

13631411
const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => {

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-native-onyx",
3-
"version": "3.0.71",
3+
"version": "3.0.72",
44
"author": "Expensify, Inc.",
55
"homepage": "https://expensify.com",
66
"description": "State management for React Native",

tests/unit/onyxUtilsTest.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ const testMergeChanges: GenericDeepRecord[] = [
7474

7575
const ONYXKEYS = {
7676
TEST_KEY: 'test',
77+
TEST_KEY_2: 'test2',
7778
COLLECTION: {
7879
TEST_KEY: 'test_',
7980
TEST_LEVEL_KEY: 'test_level_',
@@ -263,6 +264,257 @@ describe('OnyxUtils', () => {
263264
});
264265
});
265266

267+
describe('multiSetWithRetry', () => {
268+
it('should fire collection-level callback only once per collection even with multiple members', async () => {
269+
const collectionCallback = jest.fn();
270+
const connection = Onyx.connect({
271+
key: ONYXKEYS.COLLECTION.TEST_KEY,
272+
callback: collectionCallback,
273+
waitForCollectionCallback: true,
274+
initWithStoredValues: false,
275+
});
276+
277+
await waitForPromisesToResolve();
278+
collectionCallback.mockClear();
279+
280+
// multiSet with 3 members of the same collection
281+
await Onyx.multiSet({
282+
[`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1},
283+
[`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: {id: 2},
284+
[`${ONYXKEYS.COLLECTION.TEST_KEY}3`]: {id: 3},
285+
});
286+
287+
// Should be called only ONCE with the batched collection (not 3 times)
288+
expect(collectionCallback).toHaveBeenCalledTimes(1);
289+
const [collection] = collectionCallback.mock.calls[0];
290+
expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}1`]).toEqual({id: 1});
291+
expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}2`]).toEqual({id: 2});
292+
expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}3`]).toEqual({id: 3});
293+
294+
Onyx.disconnect(connection);
295+
});
296+
297+
it('should fire individual member-key subscribers once per key', async () => {
298+
const spy1 = jest.fn();
299+
const spy2 = jest.fn();
300+
const spy3 = jest.fn();
301+
302+
const conn1 = Onyx.connect({key: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, callback: spy1, initWithStoredValues: false});
303+
const conn2 = Onyx.connect({key: `${ONYXKEYS.COLLECTION.TEST_KEY}2`, callback: spy2, initWithStoredValues: false});
304+
const conn3 = Onyx.connect({key: `${ONYXKEYS.COLLECTION.TEST_KEY}3`, callback: spy3, initWithStoredValues: false});
305+
await waitForPromisesToResolve();
306+
spy1.mockClear();
307+
spy2.mockClear();
308+
spy3.mockClear();
309+
310+
await Onyx.multiSet({
311+
[`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1},
312+
[`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: {id: 2},
313+
[`${ONYXKEYS.COLLECTION.TEST_KEY}3`]: {id: 3},
314+
});
315+
316+
expect(spy1).toHaveBeenCalledTimes(1);
317+
expect(spy1).toHaveBeenCalledWith({id: 1}, `${ONYXKEYS.COLLECTION.TEST_KEY}1`);
318+
expect(spy2).toHaveBeenCalledTimes(1);
319+
expect(spy2).toHaveBeenCalledWith({id: 2}, `${ONYXKEYS.COLLECTION.TEST_KEY}2`);
320+
expect(spy3).toHaveBeenCalledTimes(1);
321+
expect(spy3).toHaveBeenCalledWith({id: 3}, `${ONYXKEYS.COLLECTION.TEST_KEY}3`);
322+
323+
Onyx.disconnect(conn1);
324+
Onyx.disconnect(conn2);
325+
Onyx.disconnect(conn3);
326+
});
327+
328+
it('should notify non-collection keys individually alongside batched collection updates', async () => {
329+
const collectionCallback = jest.fn();
330+
const singleKeyCallback = jest.fn();
331+
332+
const connCollection = Onyx.connect({
333+
key: ONYXKEYS.COLLECTION.TEST_KEY,
334+
callback: collectionCallback,
335+
waitForCollectionCallback: true,
336+
initWithStoredValues: false,
337+
});
338+
const connSingle = Onyx.connect({
339+
key: ONYXKEYS.TEST_KEY,
340+
callback: singleKeyCallback,
341+
initWithStoredValues: false,
342+
});
343+
await waitForPromisesToResolve();
344+
collectionCallback.mockClear();
345+
singleKeyCallback.mockClear();
346+
347+
// Mix of collection members and a non-collection key
348+
await Onyx.multiSet({
349+
[`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1},
350+
[`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: {id: 2},
351+
[ONYXKEYS.TEST_KEY]: 'standalone',
352+
});
353+
354+
// Collection callback fires once (batched)
355+
expect(collectionCallback).toHaveBeenCalledTimes(1);
356+
// Non-collection key callback fires once
357+
expect(singleKeyCallback).toHaveBeenCalledTimes(1);
358+
expect(singleKeyCallback).toHaveBeenCalledWith('standalone', ONYXKEYS.TEST_KEY);
359+
360+
Onyx.disconnect(connCollection);
361+
Onyx.disconnect(connSingle);
362+
});
363+
364+
it('should batch notifications per-collection when members span multiple collections', async () => {
365+
const testCallback = jest.fn();
366+
const routesCallback = jest.fn();
367+
368+
const connTest = Onyx.connect({
369+
key: ONYXKEYS.COLLECTION.TEST_KEY,
370+
callback: testCallback,
371+
waitForCollectionCallback: true,
372+
initWithStoredValues: false,
373+
});
374+
const connRoutes = Onyx.connect({
375+
key: ONYXKEYS.COLLECTION.ROUTES,
376+
callback: routesCallback,
377+
waitForCollectionCallback: true,
378+
initWithStoredValues: false,
379+
});
380+
await waitForPromisesToResolve();
381+
testCallback.mockClear();
382+
routesCallback.mockClear();
383+
384+
// multiSet with members of two different collections
385+
await Onyx.multiSet({
386+
[`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1},
387+
[`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: {id: 2},
388+
[`${ONYXKEYS.COLLECTION.ROUTES}A`]: {name: 'A'},
389+
[`${ONYXKEYS.COLLECTION.ROUTES}B`]: {name: 'B'},
390+
});
391+
392+
// Each collection callback fires once
393+
expect(testCallback).toHaveBeenCalledTimes(1);
394+
expect(routesCallback).toHaveBeenCalledTimes(1);
395+
396+
Onyx.disconnect(connTest);
397+
Onyx.disconnect(connRoutes);
398+
});
399+
400+
it('should pass previous values to keysChanged so unchanged members skip notification', async () => {
401+
// Set initial data
402+
const initial1 = {id: 1, name: 'A'};
403+
const initial2 = {id: 2, name: 'B'};
404+
await Onyx.multiSet({
405+
[`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: initial1,
406+
[`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: initial2,
407+
});
408+
409+
const spy1 = jest.fn();
410+
const spy2 = jest.fn();
411+
const conn1 = Onyx.connect({key: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, callback: spy1, initWithStoredValues: false});
412+
const conn2 = Onyx.connect({key: `${ONYXKEYS.COLLECTION.TEST_KEY}2`, callback: spy2, initWithStoredValues: false});
413+
await waitForPromisesToResolve();
414+
spy1.mockClear();
415+
spy2.mockClear();
416+
417+
// multiSet: change key 1, keep key 2 with same content (but new reference)
418+
await Onyx.multiSet({
419+
[`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1, name: 'A-updated'},
420+
[`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: initial2,
421+
});
422+
423+
// Key 1 subscriber fires (value changed)
424+
expect(spy1).toHaveBeenCalledTimes(1);
425+
expect(spy1).toHaveBeenCalledWith({id: 1, name: 'A-updated'}, `${ONYXKEYS.COLLECTION.TEST_KEY}1`);
426+
427+
// Key 2 keeps the same reference (passed as-is in multiSet) — subscriber should not fire
428+
// because keysChanged sees the same reference as previousCollection[key]
429+
expect(spy2).not.toHaveBeenCalled();
430+
431+
Onyx.disconnect(conn1);
432+
Onyx.disconnect(conn2);
433+
});
434+
435+
it('should stop firing callbacks for a collection subscriber that disconnects itself mid-batch', async () => {
436+
// A collection subscriber (waitForCollectionCallback=false) disconnects itself when
437+
// it receives the first member. Subsequent changed members in the same batch must NOT
438+
// trigger further callbacks for this subscriber.
439+
const callback = jest.fn();
440+
let connection: ReturnType<typeof Onyx.connect>;
441+
442+
callback.mockImplementation(() => {
443+
if (!connection) {
444+
return;
445+
}
446+
447+
Onyx.disconnect(connection);
448+
});
449+
450+
connection = Onyx.connect({
451+
key: ONYXKEYS.COLLECTION.TEST_KEY,
452+
callback,
453+
waitForCollectionCallback: false,
454+
initWithStoredValues: false,
455+
});
456+
await waitForPromisesToResolve();
457+
callback.mockClear();
458+
459+
await Onyx.multiSet({
460+
[`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1},
461+
[`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: {id: 2},
462+
[`${ONYXKEYS.COLLECTION.TEST_KEY}3`]: {id: 3},
463+
});
464+
465+
// Despite 3 changed members, callback should fire at most once before disconnect stops it
466+
expect(callback).toHaveBeenCalledTimes(1);
467+
});
468+
469+
it('should keep cache and subscriber state consistent when a non-collection callback writes to another payload key', async () => {
470+
// A subscriber for keyA synchronously calls Onyx.set() on keyB during its callback.
471+
// After multiSet completes, the cache must reflect the multiSet's value for keyB
472+
// (multiSet wins), and the keyB subscriber's last seen value must equal the cache.
473+
await Onyx.multiSet({[ONYXKEYS.TEST_KEY]: 'initialA', [ONYXKEYS.TEST_KEY_2]: 'initialB'});
474+
475+
const callbackA = jest.fn((value: unknown) => {
476+
if (value !== 'newA') {
477+
return;
478+
}
479+
480+
// While processing the new value of keyA, write to keyB.
481+
// keyB is later in the same multiSet payload — multiSet should win.
482+
Onyx.set(ONYXKEYS.TEST_KEY_2, 'callbackB');
483+
});
484+
const callbackB = jest.fn();
485+
486+
const connA = Onyx.connect({
487+
key: ONYXKEYS.TEST_KEY,
488+
callback: callbackA,
489+
initWithStoredValues: false,
490+
});
491+
const connB = Onyx.connect({
492+
key: ONYXKEYS.TEST_KEY_2,
493+
callback: callbackB,
494+
initWithStoredValues: false,
495+
});
496+
await waitForPromisesToResolve();
497+
callbackA.mockClear();
498+
callbackB.mockClear();
499+
500+
await Onyx.multiSet({
501+
[ONYXKEYS.TEST_KEY]: 'newA',
502+
[ONYXKEYS.TEST_KEY_2]: 'multiSetB',
503+
});
504+
505+
// Cache reflects multiSet's payload value for keyB (the multiSet's later cache.set wins)
506+
expect(OnyxCache.get(ONYXKEYS.TEST_KEY_2)).toBe('multiSetB');
507+
508+
expect(callbackB.mock.calls.length).toBe(2);
509+
expect(callbackB.mock.calls.at(0)?.[0]).toBe('callbackB');
510+
// keyB subscriber's last received value matches the cache (no stale callback)
511+
expect(callbackB.mock.calls.at(1)?.[0]).toBe('multiSetB');
512+
513+
Onyx.disconnect(connA);
514+
Onyx.disconnect(connB);
515+
});
516+
});
517+
266518
describe('keysChanged', () => {
267519
beforeEach(() => {
268520
Onyx.clear();

0 commit comments

Comments
 (0)