diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 4dc1ba2ce..acc1fe88c 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1202,6 +1202,15 @@ function updateSnapshots(data: Array>, me const snapshotCollection = getCachedCollection(snapshotCollectionKey); + // Multiset entries are keyless but update every key of their payload, so expand them into + // per-key entries to keep cached snapshots in sync with the real Onyx data. + const flattenedData = data.flatMap<{onyxMethod: OnyxMethod; key: unknown; value?: unknown}>((entry) => { + if (entry.onyxMethod === METHOD.MULTI_SET && typeof entry.key !== 'string' && entry.value && typeof entry.value === 'object' && !Array.isArray(entry.value)) { + return Object.entries(entry.value).map(([key, value]) => ({onyxMethod: METHOD.SET, key, value})); + } + return entry; + }); + for (const [snapshotEntryKey, snapshotEntryValue] of Object.entries(snapshotCollection)) { // Snapshots may not be present in cache. We don't know how to update them so we skip. if (!snapshotEntryValue) { @@ -1210,7 +1219,15 @@ function updateSnapshots(data: Array>, me let updatedData: Record = {}; - for (const {key, value} of data) { + for (const {key, value, onyxMethod} of flattenedData) { + if (typeof key !== 'string') { + // clear entries legitimately carry no key, and malformed multiset payloads are already logged by update() itself + if (onyxMethod !== METHOD.CLEAR && onyxMethod !== METHOD.MULTI_SET) { + Logger.logHmmm(`Invalid ${typeof key} key (method: ${onyxMethod}, key: ${String(key).slice(0, 50)}) provided in Onyx update. Skipping snapshot update for this entry.`); + } + continue; + } + // snapshots are normal keys so we want to skip update if they are written to Onyx if (OnyxKeys.isCollectionMemberKey(snapshotCollectionKey, key)) { continue; diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index 0021fcd8e..5612a54f4 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -1535,6 +1535,59 @@ describe('Onyx', () => { expect(callback.mock.calls[1][1]).toBe(ONYX_KEYS.COLLECTION.SNAPSHOT); }); + it('should skip update entries without a key when updating Snapshots instead of rejecting', async () => { + const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; + const snapshot1 = `${ONYX_KEYS.COLLECTION.SNAPSHOT}1`; + + const initialValue = {name: 'Fluffy'}; + const finalValue = {name: 'Kitty'}; + + await Onyx.set(cat, initialValue); + await Onyx.set(snapshot1, {data: {[cat]: initialValue}}); + + const callback = jest.fn(); + + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.SNAPSHOT, + callback, + }); + + await waitForPromisesToResolve(); + + const keylessUpdate = {onyxMethod: Onyx.METHOD.MERGE, value: {name: 'Ghost'}} as unknown as OnyxUpdate; + + await expect(Onyx.update([keylessUpdate, {key: cat, value: finalValue, onyxMethod: Onyx.METHOD.MERGE}])).resolves.not.toThrow(); + + // The valid update still lands in the snapshot. + expect(callback.mock.calls.at(-1)?.[0]).toEqual({[snapshot1]: {data: {[cat]: finalValue}}}); + }); + + it('should expand keyless multiSet updates into per-key Snapshot updates', async () => { + const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; + const dog = `${ONYX_KEYS.COLLECTION.ANIMALS}dog`; + const snapshot1 = `${ONYX_KEYS.COLLECTION.SNAPSHOT}1`; + + await Onyx.set(cat, {name: 'Fluffy'}); + await Onyx.set(dog, {name: 'Rex'}); + await Onyx.set(snapshot1, {data: {[cat]: {name: 'Fluffy'}}}); + + const callback = jest.fn(); + + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.SNAPSHOT, + callback, + }); + + await waitForPromisesToResolve(); + + const multiSetUpdate = {onyxMethod: Onyx.METHOD.MULTI_SET, value: {[cat]: {name: 'Kitty'}, [dog]: {name: 'Buddy'}}} as unknown as OnyxUpdate; + + await Onyx.update([multiSetUpdate]); + + // Only the key that exists in the snapshot is updated there, so it stays in sync with the real Onyx value. + expect(callback.mock.calls.at(-1)?.[0]).toEqual({[snapshot1]: {data: {[cat]: {name: 'Kitty'}}}}); + }); + describe('update', () => { let logInfoFn = jest.fn();