@@ -920,6 +920,283 @@ describe('OnyxUtils', () => {
920920 } ) ;
921921 } ) ;
922922
923+ describe ( 'mergeCollection pre-warm' , ( ) => {
924+ // retryOperation tests above replace StorageMock methods without restoring them, leaving
925+ // rejecting mocks behind. Capture pristine refs at file-load time and restore in beforeEach
926+ // so our Onyx.set seeding actually reaches the in-memory storage provider.
927+ const pristineSetItem = StorageMock . setItem ;
928+ const pristineMultiSet = StorageMock . multiSet ;
929+ const pristineMultiGet = StorageMock . multiGet ;
930+ const pristineGetItem = StorageMock . getItem ;
931+ const pristineMultiMerge = StorageMock . multiMerge ;
932+
933+ beforeEach ( ( ) => {
934+ StorageMock . setItem = pristineSetItem ;
935+ StorageMock . multiSet = pristineMultiSet ;
936+ StorageMock . multiGet = pristineMultiGet ;
937+ StorageMock . getItem = pristineGetItem ;
938+ StorageMock . multiMerge = pristineMultiMerge ;
939+ } ) ;
940+
941+ // Make a key "cold" — value evicted from cache but still tracked as persisted. OnyxCache.drop
942+ // also removes the key from `storageKeys`, so we re-register it afterwards to reliably hit
943+ // the cold-but-persisted state regardless of getAllKeys()'s fallback path.
944+ const evictFromCache = ( ...keys : string [ ] ) => {
945+ for ( const key of keys ) {
946+ OnyxCache . drop ( key ) ;
947+ OnyxCache . addKey ( key ) ;
948+ }
949+ } ;
950+
951+ it ( 'fast path: skips storage reads entirely when every existing key is warm in cache' , async ( ) => {
952+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
953+ const existingKey1 = `${ collectionKey } 1` ;
954+ const existingKey2 = `${ collectionKey } 2` ;
955+
956+ // Seed both members so they are both warm in cache and present in storage.
957+ await Onyx . set ( existingKey1 , { value : 'initial-1' } ) ;
958+ await Onyx . set ( existingKey2 , { value : 'initial-2' } ) ;
959+
960+ const multiGetSpy = jest . spyOn ( StorageMock , 'multiGet' ) ;
961+ const getItemSpy = jest . spyOn ( StorageMock , 'getItem' ) ;
962+
963+ await Onyx . mergeCollection ( collectionKey , {
964+ [ existingKey1 ] : { value : 'merged-1' } ,
965+ [ existingKey2 ] : { value : 'merged-2' } ,
966+ } as GenericCollection ) ;
967+
968+ // With every existingKey warm, the diff swaps Promise.all(get) for Promise.resolve(),
969+ // so no storage reads should happen during the pre-warm.
970+ expect ( multiGetSpy ) . not . toHaveBeenCalled ( ) ;
971+ expect ( getItemSpy ) . not . toHaveBeenCalled ( ) ;
972+
973+ // Cache still reflects the merge.
974+ const cached = OnyxCache . getCollectionData ( collectionKey ) ;
975+ expect ( cached ?. [ existingKey1 ] ) . toEqual ( { value : 'merged-1' } ) ;
976+ expect ( cached ?. [ existingKey2 ] ) . toEqual ( { value : 'merged-2' } ) ;
977+ } ) ;
978+
979+ it ( 'slow path: batches cold existing keys into a single Storage.multiGet, with no individual getItem calls' , async ( ) => {
980+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
981+ const coldKey1 = `${ collectionKey } 1` ;
982+ const coldKey2 = `${ collectionKey } 2` ;
983+ const warmKey = `${ collectionKey } 3` ;
984+
985+ // Seed all three in storage, then evict two from cache so they are cold-but-persisted.
986+ await Onyx . set ( coldKey1 , { value : 'persisted-1' } ) ;
987+ await Onyx . set ( coldKey2 , { value : 'persisted-2' } ) ;
988+ await Onyx . set ( warmKey , { value : 'persisted-3' } ) ;
989+ evictFromCache ( coldKey1 , coldKey2 ) ;
990+
991+ // Reset spies AFTER seeding so we only count calls made during mergeCollection itself.
992+ const multiGetSpy = jest . spyOn ( StorageMock , 'multiGet' ) . mockClear ( ) ;
993+ const getItemSpy = jest . spyOn ( StorageMock , 'getItem' ) . mockClear ( ) ;
994+
995+ await Onyx . mergeCollection ( collectionKey , {
996+ [ coldKey1 ] : { value : 'merged-1' } ,
997+ [ coldKey2 ] : { value : 'merged-2' } ,
998+ [ warmKey ] : { value : 'merged-3' } ,
999+ } as GenericCollection ) ;
1000+
1001+ // OnyxUtils.multiGet filters to cache-missing keys before issuing Storage.multiGet, so we
1002+ // expect exactly one batched read containing only the cold keys (the warm key is skipped).
1003+ expect ( multiGetSpy ) . toHaveBeenCalledTimes ( 1 ) ;
1004+ const requestedKeys = multiGetSpy . mock . calls [ 0 ] [ 0 ] as string [ ] ;
1005+ expect ( requestedKeys . sort ( ) ) . toEqual ( [ coldKey1 , coldKey2 ] . sort ( ) ) ;
1006+
1007+ // No individual Storage.getItem calls during pre-warm. Old code path would have fired one
1008+ // get() per existing key, each potentially landing in Storage.getItem on cache miss.
1009+ expect ( getItemSpy ) . not . toHaveBeenCalled ( ) ;
1010+ } ) ;
1011+
1012+ it ( 'slow path: cold-cache merge layers the new delta on top of existing storage data (no field drops)' , async ( ) => {
1013+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
1014+ const coldKey = `${ collectionKey } 1` ;
1015+
1016+ // Seed an object with multiple fields in storage, then evict from cache so the merge base
1017+ // must come from a storage read — not from `undefined`.
1018+ await Onyx . set ( coldKey , { a : 1 , b : 2 } ) ;
1019+ evictFromCache ( coldKey ) ;
1020+
1021+ await Onyx . mergeCollection ( collectionKey , {
1022+ [ coldKey ] : { c : 3 } ,
1023+ } as GenericCollection ) ;
1024+
1025+ // If the pre-warm did NOT populate the cache from storage, fastMerge would treat the
1026+ // previous value as undefined and the result would drop {a:1, b:2}. With the pre-warm
1027+ // running multiGet on the cold key, the merge layers {c:3} on top of {a:1, b:2}.
1028+ const cached = OnyxCache . getCollectionData ( collectionKey ) ;
1029+ expect ( cached ?. [ coldKey ] ) . toEqual ( { a : 1 , b : 2 , c : 3 } ) ;
1030+ } ) ;
1031+
1032+ it ( 'warm cache: subscriber receives a single merged broadcast for an Onyx.update batch (no transient undefined)' , async ( ) => {
1033+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
1034+ const existingKey = `${ collectionKey } 1` ;
1035+
1036+ await Onyx . set ( existingKey , { value : 'initial' } ) ;
1037+
1038+ const collectionCallback = jest . fn ( ) ;
1039+ Onyx . connect ( {
1040+ key : collectionKey ,
1041+ waitForCollectionCallback : true ,
1042+ callback : collectionCallback ,
1043+ } ) ;
1044+ await waitForPromisesToResolve ( ) ;
1045+ collectionCallback . mockClear ( ) ;
1046+
1047+ await Onyx . update ( [
1048+ {
1049+ onyxMethod : Onyx . METHOD . MERGE_COLLECTION ,
1050+ key : collectionKey ,
1051+ value : {
1052+ [ existingKey ] : { value : 'merged' } ,
1053+ } as GenericCollection ,
1054+ } ,
1055+ ] ) ;
1056+
1057+ // The fast path resolves the pre-warm synchronously (Promise.resolve()), preserving the
1058+ // original promise-chain depth. The Onyx.update batch must therefore broadcast exactly
1059+ // one merged value — not undefined first and the merged value on a later microtask.
1060+ const broadcasts = collectionCallback . mock . calls . map ( ( c ) => c [ 0 ] ) ;
1061+ expect ( broadcasts ) . toHaveLength ( 1 ) ;
1062+ expect ( broadcasts [ 0 ] ?. [ existingKey ] ) . toEqual ( { value : 'merged' } ) ;
1063+ } ) ;
1064+
1065+ it ( 'equivalence: warm-path and cold-path produce the same final cache state for the same merge' , async ( ) => {
1066+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
1067+ const memberKey = `${ collectionKey } 1` ;
1068+ const delta = { value : 'after' } as const ;
1069+
1070+ // Warm-path run.
1071+ await Onyx . set ( memberKey , { value : 'before' , extra : 'kept' } ) ;
1072+ await Onyx . mergeCollection ( collectionKey , {
1073+ [ memberKey ] : delta ,
1074+ } as GenericCollection ) ;
1075+ const warmResult = OnyxCache . getCollectionData ( collectionKey ) ?. [ memberKey ] ;
1076+
1077+ // Reset and replay with a cold cache before the merge.
1078+ await Onyx . clear ( ) ;
1079+ await Onyx . set ( memberKey , { value : 'before' , extra : 'kept' } ) ;
1080+ evictFromCache ( memberKey ) ;
1081+ await Onyx . mergeCollection ( collectionKey , {
1082+ [ memberKey ] : delta ,
1083+ } as GenericCollection ) ;
1084+ const coldResult = OnyxCache . getCollectionData ( collectionKey ) ?. [ memberKey ] ;
1085+
1086+ expect ( warmResult ) . toEqual ( coldResult ) ;
1087+ expect ( coldResult ) . toEqual ( { value : 'after' , extra : 'kept' } ) ;
1088+ } ) ;
1089+
1090+ it ( 'preserves cache-first invariant when Storage.multiGet rejects on the slow path' , async ( ) => {
1091+ // A Storage.multiGet rejection during pre-warm must not skip the cache.merge() +
1092+ // keysChanged() that follow. Without the .catch() at the pre-warm call site,
1093+ // subscribers would miss the merge and Onyx.mergeCollection would reject.
1094+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
1095+ const coldMemberKey = `${ collectionKey } 1` ;
1096+ const newMemberKey = `${ collectionKey } 2` ;
1097+
1098+ // Seed an existing member, then evict it from cache so it's "tracked but unloaded" —
1099+ // the slow path will try to multiGet it.
1100+ await Onyx . set ( coldMemberKey , { value : 'persisted' } ) ;
1101+ evictFromCache ( coldMemberKey ) ;
1102+
1103+ // Connect and flush the subscriber's initial load BEFORE installing the rejecting mock —
1104+ // otherwise the connect's own multiGet (no .catch) consumes the mockRejectedValueOnce and
1105+ // leaks an unhandled rejection instead of exercising the merge pre-warm path.
1106+ const collectionCallback = jest . fn ( ) ;
1107+ Onyx . connect ( {
1108+ key : collectionKey ,
1109+ waitForCollectionCallback : true ,
1110+ callback : collectionCallback ,
1111+ } ) ;
1112+ await waitForPromisesToResolve ( ) ;
1113+ collectionCallback . mockClear ( ) ;
1114+
1115+ // The subscriber's connect re-populated cache, so re-evict to force the merge into
1116+ // the slow (cold-key) path. Then reject the next Storage.multiGet so the pre-warm
1117+ // read fails.
1118+ evictFromCache ( coldMemberKey ) ;
1119+ const transientError = new Error ( 'Transient IndexedDB read error' ) ;
1120+ StorageMock . multiGet = jest . fn ( pristineMultiGet ) . mockRejectedValueOnce ( transientError ) ;
1121+
1122+ // Outer promise must resolve, not reject, even when the pre-warm read fails.
1123+ let outerRejected : unknown = null ;
1124+ const result = await Onyx . mergeCollection ( collectionKey , {
1125+ [ coldMemberKey ] : { merged : true } ,
1126+ [ newMemberKey ] : { value : 'new' } ,
1127+ } as GenericCollection ) . catch ( ( e : unknown ) => {
1128+ outerRejected = e ;
1129+ } ) ;
1130+ expect ( outerRejected ) . toBeNull ( ) ;
1131+ expect ( result ) . toBeUndefined ( ) ;
1132+
1133+ // cache.merge() + keysChanged() must still fire so subscribers see the merge. Use
1134+ // toMatchObject because a concurrent read may have re-populated the persisted value;
1135+ // what matters is that the new {merged: true} delta is applied on top.
1136+ expect ( collectionCallback ) . toHaveBeenCalled ( ) ;
1137+ const lastBroadcast = collectionCallback . mock . calls . at ( - 1 ) ?. [ 0 ] as Record < string , unknown > | undefined ;
1138+ expect ( lastBroadcast ?. [ coldMemberKey ] ) . toMatchObject ( { merged : true } ) ;
1139+ expect ( lastBroadcast ?. [ newMemberKey ] ) . toEqual ( { value : 'new' } ) ;
1140+ } ) ;
1141+ } ) ;
1142+
1143+ describe ( 'multiGet cache hit consistency' , ( ) => {
1144+ // Same suite-pollution guard as the pre-warm block above — capture pristine StorageMock
1145+ // refs at file-load time and restore them in beforeEach, since retryOperation tests leak.
1146+ const pristineSetItem = StorageMock . setItem ;
1147+ const pristineMultiGet = StorageMock . multiGet ;
1148+ const pristineGetItem = StorageMock . getItem ;
1149+
1150+ beforeEach ( ( ) => {
1151+ StorageMock . setItem = pristineSetItem ;
1152+ StorageMock . multiGet = pristineMultiGet ;
1153+ StorageMock . getItem = pristineGetItem ;
1154+ } ) ;
1155+
1156+ it ( 'does not re-fetch a cached falsy value from storage' , async ( ) => {
1157+ const falsyKey = ONYXKEYS . TEST_KEY ;
1158+
1159+ // Seed cache with the falsy value 0 (a number, but the same logic applies to '',
1160+ // false, and null). Using `Onyx.set` ensures the value lands in cache and storage.
1161+ await Onyx . set ( falsyKey , 0 ) ;
1162+
1163+ // Spy on Storage methods to confirm multiGet does NOT round-trip to storage for
1164+ // the cached falsy value.
1165+ const multiGetSpy = jest . spyOn ( StorageMock , 'multiGet' ) ;
1166+ const getItemSpy = jest . spyOn ( StorageMock , 'getItem' ) ;
1167+
1168+ const result = await OnyxUtils . multiGet ( [ falsyKey ] ) ;
1169+
1170+ // The cached value must be returned without any storage read. Before this fix,
1171+ // `if (cacheValue)` treated the cached 0 as a miss and triggered Storage.multiGet,
1172+ // which would then overwrite the warm value via cache.merge().
1173+ expect ( multiGetSpy ) . not . toHaveBeenCalled ( ) ;
1174+ expect ( getItemSpy ) . not . toHaveBeenCalled ( ) ;
1175+ expect ( result . get ( falsyKey ) ) . toBe ( 0 ) ;
1176+ } ) ;
1177+
1178+ it ( 'prefers cache when a concurrent write lands during the storage read' , async ( ) => {
1179+ // Concurrent write during multiGet's storage read must not be overwritten by the
1180+ // stale snapshot via cache.merge.
1181+ const key = `${ ONYXKEYS . COLLECTION . TEST_KEY } race` ;
1182+
1183+ OnyxCache . drop ( key ) ;
1184+ OnyxCache . addKey ( key ) ;
1185+
1186+ // Set cache inside the mock so it lands before Storage.multiGet's promise resolves —
1187+ // multiGet's .then() then sees a populated cache and skips writing the stale value.
1188+ StorageMock . multiGet = jest . fn ( ) . mockImplementation ( ( ) => {
1189+ OnyxCache . set ( key , { fresh : 'data' } ) ;
1190+ return Promise . resolve ( [ [ key , { stale : 'a' , alsoStale : 'b' } ] ] ) ;
1191+ } ) ;
1192+
1193+ const result = await OnyxUtils . multiGet ( [ key ] ) ;
1194+
1195+ expect ( OnyxCache . get ( key ) ) . toEqual ( { fresh : 'data' } ) ;
1196+ expect ( result . get ( key ) ) . toEqual ( { fresh : 'data' } ) ;
1197+ } ) ;
1198+ } ) ;
1199+
9231200 describe ( 'storage eviction' , ( ) => {
9241201 const diskFullError = new Error ( 'database or disk is full' ) ;
9251202
0 commit comments