diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 38b0549b28bc..b94166c0249d 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -156,6 +156,10 @@ function isRunning(): boolean { return isSequentialQueueRunning; } +function isPaused(): boolean { + return isQueuePaused; +} + // Flush the queue when the connection resumes NetworkStore.onReconnection(flush); @@ -191,4 +195,4 @@ function waitForIdle(): Promise { return isReadyPromise; } -export {flush, getCurrentRequest, isRunning, push, waitForIdle, pause, unpause}; +export {flush, getCurrentRequest, isRunning, isPaused, push, waitForIdle, pause, unpause}; diff --git a/src/libs/actions/OnyxUpdateManager.ts b/src/libs/actions/OnyxUpdateManager/index.ts similarity index 58% rename from src/libs/actions/OnyxUpdateManager.ts rename to src/libs/actions/OnyxUpdateManager/index.ts index 8d7f299160be..8c6695379614 100644 --- a/src/libs/actions/OnyxUpdateManager.ts +++ b/src/libs/actions/OnyxUpdateManager/index.ts @@ -3,11 +3,12 @@ import Onyx from 'react-native-onyx'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import Log from '@libs/Log'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; -import CONST from '@src/CONST'; +import * as App from '@userActions/App'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OnyxUpdatesFromServer} from '@src/types/onyx'; -import * as App from './App'; -import * as OnyxUpdates from './OnyxUpdates'; +import type {OnyxUpdatesFromServer, Response} from '@src/types/onyx'; +import {isValidOnyxUpdateFromServer} from '@src/types/onyx/OnyxUpdatesFromServer'; +import * as OnyxUpdateManagerUtils from './utils'; +import deferredUpdatesProxy from './utils/deferredUpdates'; // This file is in charge of looking at the updateIDs coming from the server and comparing them to the last updateID that the client has. // If the client is behind the server, then we need to @@ -19,15 +20,15 @@ import * as OnyxUpdates from './OnyxUpdates'; // 6. Restart the sequential queue // 7. Restart the Onyx updates from Pusher // This will ensure that the client is up-to-date with the server and all the updates have been applied in the correct order. -// It's important that this file is separate and not imported by OnyxUpdates.js, so that there are no circular dependencies. Onyx -// is used as a pub/sub mechanism to break out of the circular dependency. -// The circular dependency happens because this file calls API.GetMissingOnyxUpdates() which uses the SaveResponseInOnyx.js file -// (as a middleware). Therefore, SaveResponseInOnyx.js can't import and use this file directly. +// It's important that this file is separate and not imported by OnyxUpdates.js, so that there are no circular dependencies. +// Onyx is used as a pub/sub mechanism to break out of the circular dependency. +// The circular dependency happens because this file calls API.GetMissingOnyxUpdates() which uses the SaveResponseInOnyx.js file (as a middleware). +// Therefore, SaveResponseInOnyx.js can't import and use this file directly. -let lastUpdateIDAppliedToClient: number | null = 0; +let lastUpdateIDAppliedToClient = 0; Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, - callback: (value) => (lastUpdateIDAppliedToClient = value), + callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0), }); let isLoadingApp = false; @@ -38,17 +39,40 @@ Onyx.connect({ }, }); +let queryPromise: Promise | undefined; + +let resolveQueryPromiseWrapper: () => void; +const createQueryPromiseWrapper = () => + new Promise((resolve) => { + resolveQueryPromiseWrapper = resolve; + }); +// eslint-disable-next-line import/no-mutable-exports +let queryPromiseWrapper = createQueryPromiseWrapper(); + +const resetDeferralLogicVariables = () => { + queryPromise = undefined; + deferredUpdatesProxy.deferredUpdates = {}; +}; + +// This function will reset the query variables, unpause the SequentialQueue and log an info to the user. +function finalizeUpdatesAndResumeQueue() { + console.debug('[OnyxUpdateManager] Done applying all updates'); + + resolveQueryPromiseWrapper(); + queryPromiseWrapper = createQueryPromiseWrapper(); + + resetDeferralLogicVariables(); + Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null); + SequentialQueue.unpause(); +} + /** * * @param onyxUpdatesFromServer * @param clientLastUpdateID an optional override for the lastUpdateIDAppliedToClient * @returns */ -function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry, clientLastUpdateID = 0) { - // When there's no value, there's nothing to process, so let's return early. - if (!onyxUpdatesFromServer) { - return; - } +function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry, clientLastUpdateID?: number) { // If isLoadingApp is positive it means that OpenApp command hasn't finished yet, and in that case // we don't have base state of the app (reports, policies, etc) setup. If we apply this update, // we'll only have them overriten by the openApp response. So let's skip it and return. @@ -66,24 +90,15 @@ function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry 0) { + return; + } + console.debug(`[OnyxUpdateManager] Client is behind the server by ${Number(previousUpdateIDFromServer) - lastUpdateIDFromClient} so fetching incremental updates`); Log.info('Gap detected in update IDs from server so fetching incremental updates', true, { lastUpdateIDFromServer, previousUpdateIDFromServer, lastUpdateIDFromClient, }); - canUnpauseQueuePromise = App.getMissingOnyxUpdates(lastUpdateIDFromClient, previousUpdateIDFromServer); + + // Get the missing Onyx updates from the server and afterwards validate and apply the deferred updates. + // This will trigger recursive calls to "validateAndApplyDeferredUpdates" if there are gaps in the deferred updates. + queryPromise = App.getMissingOnyxUpdates(lastUpdateIDFromClient, previousUpdateIDFromServer).then(() => OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates(clientLastUpdateID)); } - canUnpauseQueuePromise.finally(() => { - OnyxUpdates.apply(updateParams).finally(() => { - console.debug('[OnyxUpdateManager] Done applying all updates'); - Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null); - SequentialQueue.unpause(); - }); - }); + queryPromise.finally(finalizeUpdatesAndResumeQueue); } export default () => { @@ -128,4 +156,4 @@ export default () => { }); }; -export {handleOnyxUpdateGap}; +export {handleOnyxUpdateGap, queryPromiseWrapper as queryPromise, resetDeferralLogicVariables}; diff --git a/src/libs/actions/OnyxUpdateManager/types.ts b/src/libs/actions/OnyxUpdateManager/types.ts new file mode 100644 index 000000000000..119dfb82ba1e --- /dev/null +++ b/src/libs/actions/OnyxUpdateManager/types.ts @@ -0,0 +1,7 @@ +import type {OnyxUpdatesFromServer} from '@src/types/onyx'; + +type DeferredUpdatesDictionary = Record; + +type DetectGapAndSplitResult = {applicableUpdates: DeferredUpdatesDictionary; updatesAfterGaps: DeferredUpdatesDictionary; latestMissingUpdateID: number | undefined}; + +export type {DeferredUpdatesDictionary, DetectGapAndSplitResult}; diff --git a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts new file mode 100644 index 000000000000..5cd66df6b0b0 --- /dev/null +++ b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts @@ -0,0 +1,34 @@ +import Onyx from 'react-native-onyx'; +import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import createProxyForObject from '@src/utils/createProxyForObject'; + +let lastUpdateIDAppliedToClient = 0; +Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0), +}); + +type ApplyUpdatesMockValues = { + onApplyUpdates: ((updates: DeferredUpdatesDictionary) => Promise) | undefined; +}; + +type ApplyUpdatesMock = { + applyUpdates: jest.Mock, [updates: DeferredUpdatesDictionary]>; + mockValues: ApplyUpdatesMockValues; +}; + +const mockValues: ApplyUpdatesMockValues = { + onApplyUpdates: undefined, +}; +const mockValuesProxy = createProxyForObject(mockValues); + +const applyUpdates = jest.fn((updates: DeferredUpdatesDictionary) => { + const lastUpdateIdFromUpdates = Math.max(...Object.keys(updates).map(Number)); + return (mockValuesProxy.onApplyUpdates === undefined ? Promise.resolve() : mockValuesProxy.onApplyUpdates(updates)).then(() => + Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, Math.max(lastUpdateIDAppliedToClient, lastUpdateIdFromUpdates)), + ); +}); + +export {applyUpdates, mockValuesProxy as mockValues}; +export type {ApplyUpdatesMock}; diff --git a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts new file mode 100644 index 000000000000..b4d97a4399db --- /dev/null +++ b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts @@ -0,0 +1,32 @@ +import type {DeferredUpdatesDictionary, DetectGapAndSplitResult} from '@libs/actions/OnyxUpdateManager/types'; +import createProxyForObject from '@src/utils/createProxyForObject'; +import type * as OnyxUpdateManagerUtilsImport from '..'; +import {applyUpdates} from './applyUpdates'; + +const UtilsImplementation: typeof OnyxUpdateManagerUtilsImport = jest.requireActual('@libs/actions/OnyxUpdateManager/utils'); + +type OnyxUpdateManagerUtilsMockValues = { + onValidateAndApplyDeferredUpdates: ((clientLastUpdateID?: number) => Promise) | undefined; +}; + +type OnyxUpdateManagerUtilsMock = typeof UtilsImplementation & { + detectGapsAndSplit: jest.Mock, [updates: DeferredUpdatesDictionary, clientLastUpdateID?: number]>; + validateAndApplyDeferredUpdates: jest.Mock, [clientLastUpdateID?: number]>; + mockValues: OnyxUpdateManagerUtilsMockValues; +}; + +const mockValues: OnyxUpdateManagerUtilsMockValues = { + onValidateAndApplyDeferredUpdates: undefined, +}; +const mockValuesProxy = createProxyForObject(mockValues); + +const detectGapsAndSplit = jest.fn(UtilsImplementation.detectGapsAndSplit); + +const validateAndApplyDeferredUpdates = jest.fn((clientLastUpdateID?: number) => + (mockValuesProxy.onValidateAndApplyDeferredUpdates === undefined ? Promise.resolve() : mockValuesProxy.onValidateAndApplyDeferredUpdates(clientLastUpdateID)).then(() => + UtilsImplementation.validateAndApplyDeferredUpdates(clientLastUpdateID), + ), +); + +export {applyUpdates, detectGapsAndSplit, validateAndApplyDeferredUpdates, mockValuesProxy as mockValues}; +export type {OnyxUpdateManagerUtilsMock}; diff --git a/src/libs/actions/OnyxUpdateManager/utils/applyUpdates.ts b/src/libs/actions/OnyxUpdateManager/utils/applyUpdates.ts new file mode 100644 index 000000000000..5857b079c1ba --- /dev/null +++ b/src/libs/actions/OnyxUpdateManager/utils/applyUpdates.ts @@ -0,0 +1,9 @@ +// We need to keep this in a separate file, so that we can mock this function in tests. +import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types'; +import * as OnyxUpdates from '@userActions/OnyxUpdates'; + +// This function applies a list of updates to Onyx in order and resolves when all updates have been applied +const applyUpdates = (updates: DeferredUpdatesDictionary) => Promise.all(Object.values(updates).map((update) => OnyxUpdates.apply(update))); + +// eslint-disable-next-line import/prefer-default-export +export {applyUpdates}; diff --git a/src/libs/actions/OnyxUpdateManager/utils/deferredUpdates.ts b/src/libs/actions/OnyxUpdateManager/utils/deferredUpdates.ts new file mode 100644 index 000000000000..838c27821aae --- /dev/null +++ b/src/libs/actions/OnyxUpdateManager/utils/deferredUpdates.ts @@ -0,0 +1,8 @@ +import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types'; +import createProxyForObject from '@src/utils/createProxyForObject'; + +const deferredUpdatesValue = {deferredUpdates: {} as DeferredUpdatesDictionary}; + +const deferredUpdatesProxy = createProxyForObject(deferredUpdatesValue); + +export default deferredUpdatesProxy; diff --git a/src/libs/actions/OnyxUpdateManager/utils/index.ts b/src/libs/actions/OnyxUpdateManager/utils/index.ts new file mode 100644 index 000000000000..4df22d292d19 --- /dev/null +++ b/src/libs/actions/OnyxUpdateManager/utils/index.ts @@ -0,0 +1,137 @@ +import Onyx from 'react-native-onyx'; +import * as App from '@userActions/App'; +import type {DeferredUpdatesDictionary, DetectGapAndSplitResult} from '@userActions/OnyxUpdateManager/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {applyUpdates} from './applyUpdates'; +import deferredUpdatesProxy from './deferredUpdates'; + +let lastUpdateIDAppliedToClient = 0; +Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0), +}); + +// In order for the deferred updates to be applied correctly in order, +// we need to check if there are any gaps between deferred updates. + +function detectGapsAndSplit(updates: DeferredUpdatesDictionary, clientLastUpdateID?: number): DetectGapAndSplitResult { + const lastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0; + + const updateValues = Object.values(updates); + const applicableUpdates: DeferredUpdatesDictionary = {}; + + let gapExists = false; + let firstUpdateAfterGaps: number | undefined; + let latestMissingUpdateID: number | undefined; + + for (const [index, update] of updateValues.entries()) { + const isFirst = index === 0; + + // If any update's previousUpdateID doesn't match the lastUpdateID from the previous update, the deferred updates aren't chained and there's a gap. + // For the first update, we need to check that the previousUpdateID of the fetched update is the same as the lastUpdateIDAppliedToClient. + // For any other updates, we need to check if the previousUpdateID of the current update is found in the deferred updates. + // If an update is chained, we can add it to the applicable updates. + const isChained = isFirst ? update.previousUpdateID === lastUpdateIDFromClient : !!updates[Number(update.previousUpdateID)]; + if (isChained) { + // If a gap exists already, we will not add any more updates to the applicable updates. + // Instead, once there are two chained updates again, we can set "firstUpdateAfterGaps" to the first update after the current gap. + if (gapExists) { + // If "firstUpdateAfterGaps" hasn't been set yet and there was a gap, we need to set it to the first update after all gaps. + if (!firstUpdateAfterGaps) { + firstUpdateAfterGaps = Number(update.previousUpdateID); + } + } else { + // If no gap exists yet, we can add the update to the applicable updates + applicableUpdates[Number(update.lastUpdateID)] = update; + } + } else { + // When we find a (new) gap, we need to set "gapExists" to true and reset the "firstUpdateAfterGaps" variable, + // so that we can continue searching for the next update after all gaps + gapExists = true; + firstUpdateAfterGaps = undefined; + + // If there is a gap, it means the previous update is the latest missing update. + latestMissingUpdateID = Number(update.previousUpdateID); + } + } + + // When "firstUpdateAfterGaps" is not set yet, we need to set it to the last update in the list, + // because we will fetch all missing updates up to the previous one and can then always apply the last update in the deferred updates. + const firstUpdateAfterGapWithFallback = firstUpdateAfterGaps ?? Number(updateValues[updateValues.length - 1].lastUpdateID); + + let updatesAfterGaps: DeferredUpdatesDictionary = {}; + if (gapExists) { + updatesAfterGaps = Object.entries(updates).reduce( + (accUpdates, [lastUpdateID, update]) => ({ + ...accUpdates, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...(Number(lastUpdateID) >= firstUpdateAfterGapWithFallback ? {[Number(lastUpdateID)]: update} : {}), + }), + {}, + ); + } + + return {applicableUpdates, updatesAfterGaps, latestMissingUpdateID}; +} + +// This function will check for gaps in the deferred updates and +// apply the updates in order after the missing updates are fetched and applied +function validateAndApplyDeferredUpdates(clientLastUpdateID?: number): Promise { + const lastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0; + + // We only want to apply deferred updates that are newer than the last update that was applied to the client. + // At this point, the missing updates from "GetMissingOnyxUpdates" have been applied already, so we can safely filter out. + const pendingDeferredUpdates = Object.entries(deferredUpdatesProxy.deferredUpdates).reduce( + (accUpdates, [lastUpdateID, update]) => ({ + ...accUpdates, + ...(Number(lastUpdateID) > lastUpdateIDFromClient ? {[Number(lastUpdateID)]: update} : {}), + }), + {}, + ); + + // If there are no remaining deferred updates after filtering out outdated ones, + // we can just unpause the queue and return + if (Object.values(pendingDeferredUpdates).length === 0) { + return Promise.resolve(); + } + + const {applicableUpdates, updatesAfterGaps, latestMissingUpdateID} = detectGapsAndSplit(pendingDeferredUpdates, lastUpdateIDFromClient); + + // If we detected a gap in the deferred updates, only apply the deferred updates before the gap, + // re-fetch the missing updates and then apply the remaining deferred updates after the gap + if (latestMissingUpdateID) { + return new Promise((resolve, reject) => { + deferredUpdatesProxy.deferredUpdates = {}; + + applyUpdates(applicableUpdates).then(() => { + // After we have applied the applicable updates, there might have been new deferred updates added. + // In the next (recursive) call of "validateAndApplyDeferredUpdates", + // the initial "updatesAfterGaps" and all new deferred updates will be applied in order, + // as long as there was no new gap detected. Otherwise repeat the process. + + const newLastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0; + + deferredUpdatesProxy.deferredUpdates = {...deferredUpdatesProxy.deferredUpdates, ...updatesAfterGaps}; + + // If lastUpdateIDAppliedToClient got updated in the meantime, we will just retrigger the validation and application of the current deferred updates. + if (latestMissingUpdateID <= newLastUpdateIDFromClient) { + validateAndApplyDeferredUpdates(clientLastUpdateID) + .then(() => resolve(undefined)) + .catch(reject); + return; + } + + // Then we can fetch the missing updates and apply them + App.getMissingOnyxUpdates(newLastUpdateIDFromClient, latestMissingUpdateID) + .then(() => validateAndApplyDeferredUpdates(clientLastUpdateID)) + .then(() => resolve(undefined)) + .catch(reject); + }); + }); + } + + // If there are no gaps in the deferred updates, we can apply all deferred updates in order + return applyUpdates(applicableUpdates).then(() => undefined); +} + +export {applyUpdates, detectGapsAndSplit, validateAndApplyDeferredUpdates}; diff --git a/src/libs/actions/__mocks__/App.ts b/src/libs/actions/__mocks__/App.ts new file mode 100644 index 000000000000..3d2b5814684b --- /dev/null +++ b/src/libs/actions/__mocks__/App.ts @@ -0,0 +1,76 @@ +import Onyx from 'react-native-onyx'; +import type * as AppImport from '@libs/actions/App'; +import type * as ApplyUpdatesImport from '@libs/actions/OnyxUpdateManager/utils/applyUpdates'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxUpdatesFromServer} from '@src/types/onyx'; +import createProxyForObject from '@src/utils/createProxyForObject'; + +const AppImplementation: typeof AppImport = jest.requireActual('@libs/actions/App'); +const { + setLocale, + setLocaleAndNavigate, + setSidebarLoaded, + setUpPoliciesAndNavigate, + openProfile, + redirectThirdPartyDesktopSignIn, + openApp, + reconnectApp, + confirmReadyToOpenApp, + handleRestrictedEvent, + beginDeepLinkRedirect, + beginDeepLinkRedirectAfterTransition, + finalReconnectAppAfterActivatingReliableUpdates, + savePolicyDraftByNewWorkspace, + createWorkspaceWithPolicyDraftAndNavigateToIt, + updateLastVisitedPath, + KEYS_TO_PRESERVE, +} = AppImplementation; + +type AppMockValues = { + missingOnyxUpdatesToBeApplied: OnyxUpdatesFromServer[] | undefined; +}; + +type AppActionsMock = typeof AppImport & { + getMissingOnyxUpdates: jest.Mock>; + mockValues: AppMockValues; +}; + +const mockValues: AppMockValues = { + missingOnyxUpdatesToBeApplied: undefined, +}; +const mockValuesProxy = createProxyForObject(mockValues); + +const ApplyUpdatesImplementation: typeof ApplyUpdatesImport = jest.requireActual('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); +const getMissingOnyxUpdates = jest.fn((_fromID: number, toID: number) => { + if (mockValuesProxy.missingOnyxUpdatesToBeApplied === undefined) { + return Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, toID); + } + + return ApplyUpdatesImplementation.applyUpdates(mockValuesProxy.missingOnyxUpdatesToBeApplied); +}); + +export { + // Mocks + getMissingOnyxUpdates, + mockValuesProxy as mockValues, + + // Actual App implementation + setLocale, + setLocaleAndNavigate, + setSidebarLoaded, + setUpPoliciesAndNavigate, + openProfile, + redirectThirdPartyDesktopSignIn, + openApp, + reconnectApp, + confirmReadyToOpenApp, + handleRestrictedEvent, + beginDeepLinkRedirect, + beginDeepLinkRedirectAfterTransition, + finalReconnectAppAfterActivatingReliableUpdates, + savePolicyDraftByNewWorkspace, + createWorkspaceWithPolicyDraftAndNavigateToIt, + updateLastVisitedPath, + KEYS_TO_PRESERVE, +}; +export type {AppActionsMock}; diff --git a/src/types/onyx/OnyxUpdatesFromServer.ts b/src/types/onyx/OnyxUpdatesFromServer.ts index 3c6933da19ba..0877ea6755f8 100644 --- a/src/types/onyx/OnyxUpdatesFromServer.ts +++ b/src/types/onyx/OnyxUpdatesFromServer.ts @@ -1,4 +1,5 @@ import type {OnyxUpdate} from 'react-native-onyx'; +import CONST from '@src/CONST'; import type Request from './Request'; import type Response from './Response'; @@ -21,4 +22,31 @@ type OnyxUpdatesFromServer = { updates?: OnyxUpdateEvent[]; }; +function isValidOnyxUpdateFromServer(value: unknown): value is OnyxUpdatesFromServer { + if (!value || typeof value !== 'object') { + return false; + } + if (!('type' in value) || !value.type) { + return false; + } + if (value.type === CONST.ONYX_UPDATE_TYPES.HTTPS) { + if (!('request' in value) || !value.request) { + return false; + } + + if (!('response' in value) || !value.response) { + return false; + } + } + if (value.type === CONST.ONYX_UPDATE_TYPES.PUSHER) { + if (!('updates' in value) || !value.updates) { + return false; + } + } + + return true; +} + +export {isValidOnyxUpdateFromServer}; + export type {OnyxUpdatesFromServer, OnyxUpdateEvent, OnyxServerUpdate}; diff --git a/src/utils/createProxyForObject.ts b/src/utils/createProxyForObject.ts new file mode 100644 index 000000000000..c18e5e30a0d9 --- /dev/null +++ b/src/utils/createProxyForObject.ts @@ -0,0 +1,25 @@ +/** + * Creates a proxy around an object variable that can be exported from modules, to allow modification from outside the module. + * @param value the object that should be wrapped in a proxy + * @returns A proxy object that can be modified from outside the module + */ +const createProxyForObject = >(value: Value) => + new Proxy(value, { + get: (target, property) => { + if (typeof property === 'symbol') { + return undefined; + } + + return target[property]; + }, + set: (target, property, newValue) => { + if (typeof property === 'symbol') { + return false; + } + // eslint-disable-next-line no-param-reassign + target[property as keyof Value] = newValue; + return true; + }, + }); + +export default createProxyForObject; diff --git a/tests/actions/OnyxUpdateManagerTest.ts b/tests/actions/OnyxUpdateManagerTest.ts new file mode 100644 index 000000000000..d1a10f8a4775 --- /dev/null +++ b/tests/actions/OnyxUpdateManagerTest.ts @@ -0,0 +1,280 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; +import type {AppActionsMock} from '@libs/actions/__mocks__/App'; +import * as AppImport from '@libs/actions/App'; +import applyOnyxUpdatesReliably from '@libs/actions/applyOnyxUpdatesReliably'; +import * as OnyxUpdateManagerExports from '@libs/actions/OnyxUpdateManager'; +import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types'; +import * as OnyxUpdateManagerUtilsImport from '@libs/actions/OnyxUpdateManager/utils'; +import type {OnyxUpdateManagerUtilsMock} from '@libs/actions/OnyxUpdateManager/utils/__mocks__'; +import type {ApplyUpdatesMock} from '@libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates'; +import * as ApplyUpdatesImport from '@libs/actions/OnyxUpdateManager/utils/applyUpdates'; +import * as SequentialQueue from '@libs/Network/SequentialQueue'; +import CONST from '@src/CONST'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import createOnyxMockUpdate from '../utils/createOnyxMockUpdate'; + +jest.mock('@libs/actions/App'); +jest.mock('@libs/actions/OnyxUpdateManager/utils'); +jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates', () => { + const ApplyUpdatesImplementation: typeof ApplyUpdatesImport = jest.requireActual('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); + + return { + applyUpdates: jest.fn((updates: DeferredUpdatesDictionary) => ApplyUpdatesImplementation.applyUpdates(updates)), + }; +}); + +const App = AppImport as AppActionsMock; +const ApplyUpdates = ApplyUpdatesImport as ApplyUpdatesMock; +const OnyxUpdateManagerUtils = OnyxUpdateManagerUtilsImport as OnyxUpdateManagerUtilsMock; + +const TEST_USER_ACCOUNT_ID = 1; +const REPORT_ID = 'testReport1'; +const ONYX_KEY = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const; + +const exampleReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: TEST_USER_ACCOUNT_ID, + automatic: false, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_3.png', + message: [{type: 'COMMENT', html: 'Testing a comment', text: 'Testing a comment', translationKey: ''}], + person: [{type: 'TEXT', style: 'strong', text: 'Test User'}], + shouldShow: true, +} satisfies Partial; + +const initialData = {report1: exampleReportAction, report2: exampleReportAction, report3: exampleReportAction} as unknown as OnyxTypes.ReportActions; + +const mockUpdate1 = createOnyxMockUpdate(1, [ + { + onyxMethod: OnyxUtils.METHOD.SET, + key: ONYX_KEY, + value: initialData, + }, +]); +const mockUpdate2 = createOnyxMockUpdate(2, [ + { + onyxMethod: OnyxUtils.METHOD.MERGE, + key: ONYX_KEY, + value: { + report1: null, + }, + }, +]); + +const report2PersonDiff = { + person: [ + {type: 'TEXT', style: 'light', text: 'Other Test User'}, + {type: 'TEXT', style: 'light', text: 'Other Test User 2'}, + ], +} satisfies Partial; +const report3AvatarDiff: Partial = { + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_5.png', +}; +const mockUpdate3 = createOnyxMockUpdate(3, [ + { + onyxMethod: OnyxUtils.METHOD.MERGE, + key: ONYX_KEY, + value: { + report2: report2PersonDiff, + report3: report3AvatarDiff, + }, + }, +]); +const mockUpdate4 = createOnyxMockUpdate(4, [ + { + onyxMethod: OnyxUtils.METHOD.MERGE, + key: ONYX_KEY, + value: { + report3: null, + }, + }, +]); + +const report2AvatarDiff = { + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_6.png', +} satisfies Partial; +const report4 = { + ...exampleReportAction, + automatic: true, +} satisfies Partial; +const mockUpdate5 = createOnyxMockUpdate(5, [ + { + onyxMethod: OnyxUtils.METHOD.MERGE, + key: ONYX_KEY, + value: { + report2: report2AvatarDiff, + report4, + }, + }, +]); + +OnyxUpdateManager(); + +describe('actions/OnyxUpdateManager', () => { + let reportActions: OnyxEntry; + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + Onyx.connect({ + key: ONYX_KEY, + callback: (val) => (reportActions = val), + }); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 1); + await Onyx.set(ONYX_KEY, initialData); + + OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = undefined; + App.mockValues.missingOnyxUpdatesToBeApplied = undefined; + OnyxUpdateManagerExports.resetDeferralLogicVariables(); + }); + + it('should trigger Onyx update gap handling', async () => { + // Since we don't want to trigger actual GetMissingOnyxUpdates calls to the server/backend, + // we have to mock the results of these calls. By setting the missingOnyxUpdatesToBeApplied + // property on the mock, we can simulate the results of the GetMissingOnyxUpdates calls. + App.mockValues.missingOnyxUpdatesToBeApplied = [mockUpdate2, mockUpdate3]; + + applyOnyxUpdatesReliably(mockUpdate2); + + // Delay all later updates, so that the update 2 has time to be written to storage and for the + // ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT to be updated. + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + applyOnyxUpdatesReliably(mockUpdate4); + applyOnyxUpdatesReliably(mockUpdate3); + + return OnyxUpdateManagerExports.queryPromise.then(() => { + const expectedResult: Record> = { + report2: { + ...exampleReportAction, + ...report2PersonDiff, + }, + }; + + expect(reportActions).toEqual(expectedResult); + + // GetMissingOnyxUpdates should have been called for the gap between update 2 and 4. + // Since we queued update 4 before update 3, there's a gap to resolve, before we apply the deferred updates. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(1, 2, 3); + + // After the missing updates have been applied, the applicable updates after + // all locally applied updates should be applied. (4) + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {4: mockUpdate4}); + }); + }); + + it('should trigger 2 GetMissingOnyxUpdates calls, because the deferred updates have gaps', async () => { + // Since we don't want to trigger actual GetMissingOnyxUpdates calls to the server/backend, + // we have to mock the results of these calls. By setting the missingOnyxUpdatesToBeApplied + // property on the mock, we can simulate the results of the GetMissingOnyxUpdates calls. + App.mockValues.missingOnyxUpdatesToBeApplied = [mockUpdate1, mockUpdate2]; + + applyOnyxUpdatesReliably(mockUpdate3); + applyOnyxUpdatesReliably(mockUpdate5); + + let finishFirstCall: () => void; + const firstGetMissingOnyxUpdatesCallFinished = new Promise((resolve) => { + finishFirstCall = resolve; + }); + + OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = () => { + // After the first GetMissingOnyxUpdates call has been resolved, + // we have to set the mocked results of for the second call. + App.mockValues.missingOnyxUpdatesToBeApplied = [mockUpdate3, mockUpdate4]; + finishFirstCall(); + return Promise.resolve(); + }; + + return firstGetMissingOnyxUpdatesCallFinished + .then(() => OnyxUpdateManagerExports.queryPromise) + .then(() => { + const expectedResult: Record> = { + report2: { + ...exampleReportAction, + ...report2PersonDiff, + ...report2AvatarDiff, + }, + report4, + }; + + expect(reportActions).toEqual(expectedResult); + + // GetMissingOnyxUpdates should have been called twice, once for the gap between update 1 and 3, + // and once for the gap between update 3 and 5. + // We always fetch missing updates from the lastUpdateIDAppliedToClient + // to previousUpdateID of the first deferred update. First 1-2, second 3-4 + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(1, 1, 2); + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 3, 4); + + // Since we have two GetMissingOnyxUpdates calls, there will be two sets of applicable updates. + // The first applicable update will be 3, after missing updates 1-2 have been applied. + // The second applicable update will be 5, after missing updates 3-4 have been applied. + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(2); + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: mockUpdate3}); + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {5: mockUpdate5}); + }); + }); + + it('should pause SequentialQueue while missing updates are being fetched', async () => { + // Since we don't want to trigger actual GetMissingOnyxUpdates calls to the server/backend, + // we have to mock the results of these calls. By setting the missingOnyxUpdatesToBeApplied + // property on the mock, we can simulate the results of the GetMissingOnyxUpdates calls. + App.mockValues.missingOnyxUpdatesToBeApplied = [mockUpdate1, mockUpdate2]; + + applyOnyxUpdatesReliably(mockUpdate3); + applyOnyxUpdatesReliably(mockUpdate5); + + const assertAfterFirstGetMissingOnyxUpdates = () => { + // While the fetching of missing udpates and the validation and application of the deferred updaes is running, + // the SequentialQueue should be paused. + expect(SequentialQueue.isPaused()).toBeTruthy(); + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(1, 1, 2); + }; + + const assertAfterSecondGetMissingOnyxUpdates = () => { + // The SequentialQueue should still be paused. + expect(SequentialQueue.isPaused()).toBeTruthy(); + expect(SequentialQueue.isRunning()).toBeFalsy(); + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 3, 4); + }; + + let firstCallFinished = false; + OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = () => { + if (firstCallFinished) { + assertAfterSecondGetMissingOnyxUpdates(); + return Promise.resolve(); + } + + assertAfterFirstGetMissingOnyxUpdates(); + + // After the first GetMissingOnyxUpdates call has been resolved, + // we have to set the mocked results of for the second call. + App.mockValues.missingOnyxUpdatesToBeApplied = [mockUpdate3, mockUpdate4]; + firstCallFinished = true; + return Promise.resolve(); + }; + + return OnyxUpdateManagerExports.queryPromise.then(() => { + // Once the OnyxUpdateManager has finished filling the gaps, the SequentialQueue should be unpaused again. + // It must not necessarily be running, because it might not have been flushed yet. + expect(SequentialQueue.isPaused()).toBeFalsy(); + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/tests/unit/OnyxUpdateManagerTest.ts b/tests/unit/OnyxUpdateManagerTest.ts new file mode 100644 index 000000000000..20b29ca18a56 --- /dev/null +++ b/tests/unit/OnyxUpdateManagerTest.ts @@ -0,0 +1,257 @@ +import Onyx from 'react-native-onyx'; +import type {AppActionsMock} from '@libs/actions/__mocks__/App'; +import * as AppImport from '@libs/actions/App'; +import * as OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; +import * as OnyxUpdateManagerUtilsImport from '@libs/actions/OnyxUpdateManager/utils'; +import type {OnyxUpdateManagerUtilsMock} from '@libs/actions/OnyxUpdateManager/utils/__mocks__'; +import type {ApplyUpdatesMock} from '@libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates'; +import * as ApplyUpdatesImport from '@libs/actions/OnyxUpdateManager/utils/applyUpdates'; +import ONYXKEYS from '@src/ONYXKEYS'; +import createOnyxMockUpdate from '../utils/createOnyxMockUpdate'; + +jest.mock('@libs/actions/App'); +jest.mock('@libs/actions/OnyxUpdateManager/utils'); +jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); + +const App = AppImport as AppActionsMock; +const ApplyUpdates = ApplyUpdatesImport as ApplyUpdatesMock; +const OnyxUpdateManagerUtils = OnyxUpdateManagerUtilsImport as OnyxUpdateManagerUtilsMock; + +const mockUpdate3 = createOnyxMockUpdate(3); +const mockUpdate4 = createOnyxMockUpdate(4); +const mockUpdate5 = createOnyxMockUpdate(5); +const mockUpdate6 = createOnyxMockUpdate(6); +const mockUpdate7 = createOnyxMockUpdate(7); +const mockUpdate8 = createOnyxMockUpdate(8); + +describe('OnyxUpdateManager', () => { + let lastUpdateIDAppliedToClient = 1; + beforeAll(() => { + Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (value) => (lastUpdateIDAppliedToClient = value ?? 1), + }); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 1); + OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = undefined; + ApplyUpdates.mockValues.onApplyUpdates = undefined; + OnyxUpdateManager.resetDeferralLogicVariables(); + }); + + it('should fetch missing Onyx updates once, defer updates and apply after missing updates', () => { + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate4); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate5); + + return OnyxUpdateManager.queryPromise.then(() => { + // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. + expect(lastUpdateIDAppliedToClient).toBe(5); + + // There are no gaps in the deferred updates, therefore only one call to getMissingOnyxUpdates should be triggered + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); + + // validateAndApplyDeferredUpdates should be called twice, once for the initial deferred updates and once for the remaining deferred updates with gaps. + // Unfortunately, we cannot easily count the calls of this function with Jest, since it recursively calls itself. + // The intended assertion would look like this: + // expect(OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates).toHaveBeenCalledTimes(1); + + // There should be only one call to applyUpdates. The call should contain all the deferred update, + // since the locally applied updates have changed in the meantime. + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledWith({3: mockUpdate3, 4: mockUpdate4, 5: mockUpdate5}); + }); + }); + + it('should only apply deferred updates that are newer than the last locally applied update (pending updates)', async () => { + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate4); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate5); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate6); + + OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = async () => { + // We manually update the lastUpdateIDAppliedToClient to 5, to simulate local updates being applied, + // while we are waiting for the missing updates to be fetched. + // Only the deferred updates after the lastUpdateIDAppliedToClient should be applied. + await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 5); + OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = undefined; + }; + + return OnyxUpdateManager.queryPromise.then(() => { + // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. + expect(lastUpdateIDAppliedToClient).toBe(6); + + // There are no gaps in the deferred updates, therefore only one call to getMissingOnyxUpdates should be triggered + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); + + // validateAndApplyDeferredUpdates should be called twice, once for the initial deferred updates and once for the remaining deferred updates with gaps. + // Unfortunately, we cannot easily count the calls of this function with Jest, since it recursively calls itself. + // The intended assertion would look like this: + // expect(OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates).toHaveBeenCalledTimes(1); + + // Missing updates from 1 (last applied to client) to 3 (last "previousUpdateID" from first deferred update) should have been fetched from the server in the first and only call to getMissingOnyxUpdates + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(1, 1, 3); + + // There should be only one call to applyUpdates. The call should only contain the last deferred update, + // since the locally applied updates have changed in the meantime. + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); + + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledWith({6: mockUpdate6}); + }); + }); + + it('should re-fetch missing updates if the deferred updates have a gap', async () => { + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate5); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate6); + + return OnyxUpdateManager.queryPromise.then(() => { + // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. + expect(lastUpdateIDAppliedToClient).toBe(6); + + // Even though there is a gap in the deferred updates, we only want to fetch missing updates once per batch. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(2); + + // validateAndApplyDeferredUpdates should be called twice, once for the initial deferred updates and once for the remaining deferred updates with gaps. + // Unfortunately, we cannot easily count the calls of this function with Jest, since it recursively calls itself. + // The intended assertion would look like this: + // expect(OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates).toHaveBeenCalledTimes(2); + + // There should be multiple calls getMissingOnyxUpdates and applyUpdates, since we detect a gap in the deferred updates. + // The first call to getMissingOnyxUpdates should fetch updates from 1 (last applied to client) to 2 (last "previousUpdateID" from first deferred update) from the server. + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(1, 1, 2); + + // After the initial missing updates have been applied, the applicable updates (3) should be applied. + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: mockUpdate3}); + + // The second call to getMissingOnyxUpdates should fetch the missing updates from the gap in the deferred updates. 3-4 + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 3, 4); + + // After the gap in the deferred updates has been resolved, the remaining deferred updates (5, 6) should be applied. + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {5: mockUpdate5, 6: mockUpdate6}); + }); + }); + + it('should re-fetch missing deferred updates only once per batch', async () => { + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate4); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate6); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate8); + + return OnyxUpdateManager.queryPromise.then(() => { + // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. + expect(lastUpdateIDAppliedToClient).toBe(8); + + // Even though there are multiple gaps in the deferred updates, we only want to fetch missing updates once per batch. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(2); + // validateAndApplyDeferredUpdates should be called twice, once for the initial deferred updates and once for the remaining deferred updates with gaps. + // Unfortunately, we cannot easily count the calls of this function with Jest, since it recursively calls itself. + // The intended assertion would look like this: + // expect(OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates).toHaveBeenCalledTimes(2); + + // After the initial missing updates have been applied, the applicable updates (3-4) should be applied. + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: mockUpdate3, 4: mockUpdate4}); + + // The second call to getMissingOnyxUpdates should fetch the missing updates from the gap (4-7) in the deferred updates. + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 4, 7); + + // After the gap in the deferred updates has been resolved, the remaining deferred updates (8) should be applied. + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {8: mockUpdate8}); + }); + }); + + it('should not re-fetch missing updates if the lastUpdateIDFromClient has been updated', async () => { + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate5); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate6); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate7); + + ApplyUpdates.mockValues.onApplyUpdates = async () => { + // We manually update the lastUpdateIDAppliedToClient to 5, to simulate local updates being applied, + // while the applicable updates have been applied. + // When this happens, the OnyxUpdateManager should trigger another validation of the deferred updates, + // without triggering another re-fetching of missing updates from the server. + await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 5); + ApplyUpdates.mockValues.onApplyUpdates = undefined; + }; + + return OnyxUpdateManager.queryPromise.then(() => { + // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. + expect(lastUpdateIDAppliedToClient).toBe(7); + + // Even though there are multiple gaps in the deferred updates, we only want to fetch missing updates once per batch. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + + // validateAndApplyDeferredUpdates should be called twice, once for the initial deferred updates and once for the remaining deferred updates with gaps. + // Unfortunately, we cannot easily count the calls of this function with Jest, since it recursively calls itself. + // The intended assertion would look like this: + // expect(OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates).toHaveBeenCalledTimes(1); + + // Since there is a gap in the deferred updates, we need to run applyUpdates twice. + // Once for the applicable updates (before the gap) and then for the remaining deferred updates. + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(2); + + // After the initial missing updates have been applied, the applicable updates (3) should be applied. + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: mockUpdate3}); + + // Since the lastUpdateIDAppliedToClient has changed to 5 in the meantime, we only need to apply the remaining deferred updates (6-7). + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {6: mockUpdate6, 7: mockUpdate7}); + }); + }); + + it('should re-fetch missing updates if the lastUpdateIDFromClient has increased, but there are still gaps after the locally applied update', async () => { + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); + OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate7); + + ApplyUpdates.mockValues.onApplyUpdates = async () => { + // We manually update the lastUpdateIDAppliedToClient to 4, to simulate local updates being applied, + // while the applicable updates have been applied. + // When this happens, the OnyxUpdateManager should trigger another validation of the deferred updates, + // without triggering another re-fetching of missing updates from the server. + await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 4); + ApplyUpdates.mockValues.onApplyUpdates = undefined; + }; + + return OnyxUpdateManager.queryPromise.then(() => { + // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. + expect(lastUpdateIDAppliedToClient).toBe(7); + + // Even though there are multiple gaps in the deferred updates, we only want to fetch missing updates once per batch. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); + + // validateAndApplyDeferredUpdates should be called twice, once for the initial deferred updates and once for the remaining deferred updates with gaps. + // Unfortunately, we cannot easily count the calls of this function with Jest, since it recursively calls itself. + // The intended assertion would look like this: + // expect(OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates).toHaveBeenCalledTimes(2); + + // Since there is a gap in the deferred updates, we need to run applyUpdates twice. + // Once for the applicable updates (before the gap) and then for the remaining deferred updates. + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(2); + + // After the initial missing updates have been applied, the applicable updates (3) should be applied. + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: mockUpdate3}); + + // The second call to getMissingOnyxUpdates should fetch the missing updates from the gap in the deferred updates, + // that are later than the locally applied update (4-6). (including the last locally applied update) + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 4, 6); + + // Since the lastUpdateIDAppliedToClient has changed to 4 in the meantime and we're fetching updates 5-6 we only need to apply the remaining deferred updates (7). + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {7: mockUpdate7}); + }); + }); +}); diff --git a/tests/utils/createOnyxMockUpdate.ts b/tests/utils/createOnyxMockUpdate.ts new file mode 100644 index 000000000000..f8e28e364cf7 --- /dev/null +++ b/tests/utils/createOnyxMockUpdate.ts @@ -0,0 +1,23 @@ +import type {OnyxUpdate} from 'react-native-onyx'; +import type {OnyxUpdatesFromServer} from '@src/types/onyx'; + +const createOnyxMockUpdate = (lastUpdateID: number, successData: OnyxUpdate[] = []): OnyxUpdatesFromServer => ({ + type: 'https', + lastUpdateID, + previousUpdateID: lastUpdateID - 1, + request: { + command: 'TestCommand', + successData, + failureData: [], + finallyData: [], + optimisticData: [], + }, + response: { + jsonCode: 200, + lastUpdateID, + previousUpdateID: lastUpdateID - 1, + onyxData: successData, + }, +}); + +export default createOnyxMockUpdate;