Skip to content

Commit ee5a885

Browse files
authored
Merge pull request #778 from callstack-internal/feature/structural-sharing-cache-pr-5-standalone
[Structural Sharing] PR 5: Batched multiSetWithRetry notifications
2 parents a23f03c + 283159e commit ee5a885

2 files changed

Lines changed: 305 additions & 5 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}`);
@@ -1356,16 +1374,46 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
13561374

13571375
const keyValuePairsToSet = OnyxUtils.prepareKeyValuePairsForStorage(newData, true);
13581376

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

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

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

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_',
@@ -266,6 +267,257 @@ describe('OnyxUtils', () => {
266267
});
267268
});
268269

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

0 commit comments

Comments
 (0)