@@ -74,6 +74,7 @@ const testMergeChanges: GenericDeepRecord[] = [
7474
7575const 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