diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 15991694..4bf93ca0 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -40,6 +40,7 @@ function init({ enablePerformanceMetrics = false, enableDevTools = true, skippableCollectionMemberIDs = [], + snapshotMergeKeys = [], }: InitOptions): void { if (enablePerformanceMetrics) { GlobalSettings.setPerformanceMetricsEnabled(true); @@ -51,6 +52,7 @@ function init({ Storage.init(); OnyxUtils.setSkippableCollectionMemberIDs(new Set(skippableCollectionMemberIDs)); + OnyxUtils.setSnapshotMergeKeys(new Set(snapshotMergeKeys)); if (shouldSyncMultipleInstances) { Storage.keepInstancesSync?.((key, value) => { diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 88939742..2f73463c 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1,6 +1,5 @@ import {deepEqual} from 'fast-equals'; import type {ValueOf} from 'type-fest'; -import lodashPick from 'lodash/pick'; import _ from 'underscore'; import DevTools from './DevTools'; import * as Logger from './Logger'; @@ -103,6 +102,8 @@ const deferredInitTask = createDeferredTask(); // Holds a set of collection member IDs which updates will be ignored when using Onyx methods. let skippableCollectionMemberIDs = new Set(); +// Holds a set of keys that should always be merged into snapshot entries. +let snapshotMergeKeys = new Set(); function getSnapshotKey(): OnyxKey | null { return snapshotKey; @@ -143,6 +144,13 @@ function getSkippableCollectionMemberIDs(): Set { return skippableCollectionMemberIDs; } +/** + * Getter - returns the snapshot merge keys allowlist. + */ +function getSnapshotMergeKeys(): Set { + return snapshotMergeKeys; +} + /** * Setter - sets the skippable collection member IDs. */ @@ -150,6 +158,13 @@ function setSkippableCollectionMemberIDs(ids: Set): void { skippableCollectionMemberIDs = ids; } +/** + * Setter - sets the snapshot merge keys allowlist. + */ +function setSnapshotMergeKeys(keys: Set): void { + snapshotMergeKeys = keys; +} + /** * Sets the initial values for the Onyx store * @@ -1234,7 +1249,15 @@ function updateSnapshots(data: Array>, me } const oldValue = updatedData[key] || {}; - const newValue = lodashPick(value, Object.keys(snapshotData[key])); + + // Snapshot entries are stored as a "shape" of the last known data per key, so by default we only + // merge fields that already exist in the snapshot to avoid unintentionally bloating snapshot data. + // Some clients need specific fields (like pending status) even when they are missing in the snapshot, + // so we allow an explicit, opt-in list of keys to always include during snapshot merges. + const snapshotExistingKeys = Object.keys(snapshotData[key] || {}); + const allowedNewKeys = getSnapshotMergeKeys(); + const keysToCopy = new Set([...snapshotExistingKeys, ...allowedNewKeys]); + const newValue = typeof value === 'object' && value !== null ? utils.pick(value as Record, [...keysToCopy]) : {}; updatedData = {...updatedData, [key]: Object.assign(oldValue, newValue)}; } @@ -1704,6 +1727,8 @@ const OnyxUtils = { unsubscribeFromKey, getSkippableCollectionMemberIDs, setSkippableCollectionMemberIDs, + getSnapshotMergeKeys, + setSnapshotMergeKeys, storeKeyBySubscriptions, deleteKeyBySubscriptions, addKeyToRecentlyAccessedIfNeeded, diff --git a/lib/types.ts b/lib/types.ts index 2cd4b75f..94b6622b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -425,6 +425,15 @@ type InitOptions = { * Additionally, any subscribers from these keys to won't receive any data from Onyx. */ skippableCollectionMemberIDs?: string[]; + + /** + * A list of field names that should always be merged into snapshot entries even if those fields are + * missing in the snapshot. Snapshots are saved "views" of a key's data used to populate read-only + * or cached lists, and by default Onyx only merges fields that already exist in that saved view. + * Use this to opt-in to additional fields that must appear in snapshots (for example, pending flags) + * without hardcoding app-specific logic inside Onyx. + */ + snapshotMergeKeys?: string[]; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index b432bf82..f0525109 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -34,6 +34,7 @@ Onyx.init({ [ONYX_KEYS.KEY_WITH_UNDERSCORE]: 'default', }, skippableCollectionMemberIDs: ['skippable-id'], + snapshotMergeKeys: ['pendingAction', 'pendingFields'], }); describe('Onyx', () => { @@ -1576,6 +1577,37 @@ describe('Onyx', () => { expect(callback).toHaveBeenNthCalledWith(2, {data: {[cat]: finalValue}}, snapshot1); }); + it('should merge allowlisted keys into Snapshot even if they were missing', async () => { + const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; + const snapshot1 = `${ONYX_KEYS.COLLECTION.SNAPSHOT}1`; + + const initialValue = {name: 'Fluffy'}; + const finalValue = { + name: 'Kitty', + pendingAction: 'delete', + pendingFields: {preview: 'delete'}, + other: 'ignored', + }; + + 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(); + + await Onyx.update([{key: cat, value: finalValue, onyxMethod: Onyx.METHOD.MERGE}]); + + expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, {data: {[cat]: initialValue}}, snapshot1); + expect(callback).toHaveBeenNthCalledWith(2, {data: {[cat]: {name: 'Kitty', pendingAction: 'delete', pendingFields: {preview: 'delete'}}}}, snapshot1); + }); + describe('update', () => { it('should squash all updates of collection-related keys into a single mergeCollection call', () => { const connections: Connection[] = [];