@@ -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_' ,
@@ -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