diff --git a/src/libs/Middleware/SaveResponseInOnyx.ts b/src/libs/Middleware/SaveResponseInOnyx.ts index 12c1931b0199..1a092f131e04 100644 --- a/src/libs/Middleware/SaveResponseInOnyx.ts +++ b/src/libs/Middleware/SaveResponseInOnyx.ts @@ -25,13 +25,16 @@ const SaveResponseInOnyx: Middleware = (requestResponse, request) => const responseToApply = { type: CONST.ONYX_UPDATE_TYPES.HTTPS, - lastUpdateID: Number(response?.lastUpdateID ?? 0), - previousUpdateID: Number(response?.previousUpdateID ?? 0), + lastUpdateID: Number(response?.lastUpdateID ?? CONST.DEFAULT_NUMBER_ID), + previousUpdateID: Number(response?.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID), request, response: response ?? {}, }; - if (requestsToIgnoreLastUpdateID.includes(request.command) || !OnyxUpdates.doesClientNeedToBeUpdated(Number(response?.previousUpdateID ?? 0))) { + if ( + requestsToIgnoreLastUpdateID.includes(request.command) || + !OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: Number(response?.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID)}) + ) { return OnyxUpdates.apply(responseToApply); } diff --git a/src/libs/Notification/PushNotification/NotificationType.ts b/src/libs/Notification/PushNotification/NotificationType.ts index 0ae8d9cc488e..28d98c7aec42 100644 --- a/src/libs/Notification/PushNotification/NotificationType.ts +++ b/src/libs/Notification/PushNotification/NotificationType.ts @@ -19,6 +19,7 @@ type BasePushNotificationData = { onyxData?: OnyxServerUpdate[]; lastUpdateID?: number; previousUpdateID?: number; + hasPendingOnyxUpdates?: boolean; }; type ReportActionPushNotificationData = BasePushNotificationData & { diff --git a/src/libs/Notification/PushNotification/subscribePushNotification/index.ts b/src/libs/Notification/PushNotification/subscribePushNotification/index.ts index 61af079f9ed1..237a615b570a 100644 --- a/src/libs/Notification/PushNotification/subscribePushNotification/index.ts +++ b/src/libs/Notification/PushNotification/subscribePushNotification/index.ts @@ -43,12 +43,12 @@ function getLastUpdateIDAppliedToClient(): Promise { return new Promise((resolve) => { Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, - callback: (value) => resolve(value ?? 0), + callback: (value) => resolve(value ?? CONST.DEFAULT_NUMBER_ID), }); }); } -function applyOnyxData({reportID, reportActionID, onyxData, lastUpdateID, previousUpdateID}: ReportActionPushNotificationData): Promise { +function applyOnyxData({reportID, reportActionID, onyxData, lastUpdateID, previousUpdateID, hasPendingOnyxUpdates = false}: ReportActionPushNotificationData): Promise { Log.info(`[PushNotification] Applying onyx data in the ${Visibility.isVisible() ? 'foreground' : 'background'}`, false, {reportID, reportActionID}); if (!ActiveClientManager.isClientTheLeader()) { @@ -56,30 +56,56 @@ function applyOnyxData({reportID, reportActionID, onyxData, lastUpdateID, previo return Promise.resolve(); } - if (!onyxData || !lastUpdateID || !previousUpdateID) { - Log.hmmm("[PushNotification] didn't apply onyx updates because some data is missing", {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); - return Promise.resolve(); - } + const logMissingOnyxDataInfo = (isDataMissing: boolean): boolean => { + if (isDataMissing) { + Log.hmmm("[PushNotification] didn't apply onyx updates because some data is missing", {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); + return false; + } - Log.info('[PushNotification] reliable onyx update received', false, {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); - const updates: OnyxUpdatesFromServer = { - type: CONST.ONYX_UPDATE_TYPES.AIRSHIP, - lastUpdateID, - previousUpdateID, - updates: [ - { - eventType: '', // This is only needed for Pusher events - data: onyxData, - }, - ], + Log.info('[PushNotification] reliable onyx update received', false, {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); + return true; }; + let updates: OnyxUpdatesFromServer; + if (hasPendingOnyxUpdates) { + const isDataMissing = !lastUpdateID; + logMissingOnyxDataInfo(isDataMissing); + if (isDataMissing) { + return Promise.resolve(); + } + + updates = { + type: CONST.ONYX_UPDATE_TYPES.AIRSHIP, + lastUpdateID, + shouldFetchPendingUpdates: true, + updates: [], + }; + } else { + const isDataMissing = !lastUpdateID || !onyxData || !previousUpdateID; + logMissingOnyxDataInfo(isDataMissing); + if (isDataMissing) { + return Promise.resolve(); + } + + updates = { + type: CONST.ONYX_UPDATE_TYPES.AIRSHIP, + lastUpdateID, + previousUpdateID, + updates: [ + { + eventType: '', // This is only needed for Pusher events + data: onyxData, + }, + ], + }; + } + /** * When this callback runs in the background on Android (via Headless JS), no other Onyx.connect callbacks will run. This means that * lastUpdateIDAppliedToClient will NOT be populated in other libs. To workaround this, we manually read the value here * and pass it as a param */ - return getLastUpdateIDAppliedToClient().then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, true, lastUpdateIDAppliedToClient)); + return getLastUpdateIDAppliedToClient().then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, {shouldRunSync: true, clientLastUpdateID: lastUpdateIDAppliedToClient})); } function navigateToReport({reportID, reportActionID}: ReportActionPushNotificationData): Promise { diff --git a/src/libs/actions/OnyxUpdateManager/index.ts b/src/libs/actions/OnyxUpdateManager/index.ts index 085e05b0a449..dad3e4b15f35 100644 --- a/src/libs/actions/OnyxUpdateManager/index.ts +++ b/src/libs/actions/OnyxUpdateManager/index.ts @@ -6,6 +6,7 @@ import * as NetworkStore from '@libs/Network/NetworkStore'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; import * as App from '@userActions/App'; import updateSessionAuthTokens from '@userActions/Session/updateSessionAuthTokens'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxUpdatesFromServer, Session} from '@src/types/onyx'; import {isValidOnyxUpdateFromServer} from '@src/types/onyx/OnyxUpdatesFromServer'; @@ -27,10 +28,10 @@ import * as DeferredOnyxUpdates from './utils/DeferredOnyxUpdates'; // 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 = 0; +let lastUpdateIDAppliedToClient: number = CONST.DEFAULT_NUMBER_ID; Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, - callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0), + callback: (value) => (lastUpdateIDAppliedToClient = value ?? CONST.DEFAULT_NUMBER_ID), }); let isLoadingApp = false; @@ -48,6 +49,7 @@ const createQueryPromiseWrapper = () => }); // eslint-disable-next-line import/no-mutable-exports let queryPromiseWrapper = createQueryPromiseWrapper(); +let isFetchingForPendingUpdates = false; const resetDeferralLogicVariables = () => { DeferredOnyxUpdates.clear({shouldUnpauseSequentialQueue: false}); @@ -61,18 +63,19 @@ function finalizeUpdatesAndResumeQueue() { queryPromiseWrapper = createQueryPromiseWrapper(); DeferredOnyxUpdates.clear(); + isFetchingForPendingUpdates = false; } /** - * - * @param onyxUpdatesFromServer + * Triggers the fetching process of either pending or missing updates. + * @param onyxUpdatesFromServer the current update that is supposed to be applied * @param clientLastUpdateID an optional override for the lastUpdateIDAppliedToClient * @returns */ -function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry, clientLastUpdateID?: number) { +function handleMissingOnyxUpdates(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. + // we don't have base state of the app (reports, policies, etc.) setup. If we apply this update, + // we'll only have them overwritten by the openApp response. So let's skip it and return. if (isLoadingApp) { // When ONYX_UPDATES_FROM_SERVER is set, we pause the queue. Let's unpause // it so the app is not stuck forever without processing requests. @@ -96,48 +99,81 @@ function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry { + // The OnyxUpdateManager can handle different types of re-fetch processes. Either there are pending updates, + // that we need to fetch manually, or we detected gaps in the previously fetched updates. + // Each of the flows below sets a promise through `DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise`, which we further process. + if (shouldFetchPendingUpdates) { + // This flow handles the case where the server didn't send updates because the payload was too big. + // We need to call the GetMissingOnyxUpdates query to fetch the missing updates up to the pendingLastUpdateID. + const pendingUpdateID = Number(lastUpdateIDFromServer); + + isFetchingForPendingUpdates = true; + + // If the pendingUpdateID is not newer than the last locally applied update, we don't need to fetch the missing updates. + if (pendingUpdateID <= lastUpdateIDFromClient) { + DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise(Promise.resolve()); + return true; + } + + console.debug(`[OnyxUpdateManager] Client is fetching pending updates from the server, from updates ${lastUpdateIDFromClient} to ${Number(pendingUpdateID)}`); + Log.info('There are pending updates from the server, so fetching incremental updates', true, { + pendingUpdateID, + lastUpdateIDFromClient, + }); + + // Get the missing Onyx updates from the server and afterward validate and apply the deferred updates. + // This will trigger recursive calls to "validateAndApplyDeferredUpdates" if there are gaps in the deferred updates. + DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise( + App.getMissingOnyxUpdates(lastUpdateIDFromClient, lastUpdateIDFromServer).then(() => OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates(clientLastUpdateID)), + ); + + return true; } - Log.info('Client has not gotten reliable updates before so reconnecting the app to start the process'); + if (!lastUpdateIDFromClient) { + // This is the first time we're receiving an lastUpdateID, so we need to do a final ReconnectApp query before + // This flow is setting the promise to a ReconnectApp query. + + // If there is a ReconnectApp query in progress, we should not start another one. + if (DeferredOnyxUpdates.getMissingOnyxUpdatesQueryPromise()) { + return false; + } + + Log.info('Client has not gotten reliable updates before so reconnecting the app to start the process'); + + // Since this is a full reconnectApp, we'll not apply the updates we received - those will come in the reconnect app request. + DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise(App.finalReconnectAppAfterActivatingReliableUpdates()); + + return true; + } - // Since this is a full reconnectApp, we'll not apply the updates we received - those will come in the reconnect app request. - DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise(App.finalReconnectAppAfterActivatingReliableUpdates()); - } else { - // The flow below is setting the promise to a getMissingOnyxUpdates to address flow (2) explained above. + // This client already has the reliable updates mode enabled, but it's missing some updates and it needs to fetch those. + // Therefore, we are calling the GetMissingOnyxUpdates query, to fetch the missing updates. const areDeferredUpdatesQueued = !DeferredOnyxUpdates.isEmpty(); // Add the new update to the deferred updates - DeferredOnyxUpdates.enqueue(updateParams, {shouldPauseSequentialQueue: false}); + DeferredOnyxUpdates.enqueue(onyxUpdatesFromServer, {shouldPauseSequentialQueue: false}); // If there are deferred updates already, we don't need to fetch the missing updates again. - if (areDeferredUpdatesQueued) { - return; + if (areDeferredUpdatesQueued || isFetchingForPendingUpdates) { + return false; } - 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, { + console.debug(`[OnyxUpdateManager] Client is fetching missing updates from the server, from updates ${lastUpdateIDFromClient} to ${Number(previousUpdateIDFromServer)}`); + Log.info('Gap detected in update IDs from the server so fetching incremental updates', true, { + lastUpdateIDFromClient, lastUpdateIDFromServer, previousUpdateIDFromServer, - lastUpdateIDFromClient, }); // Get the missing Onyx updates from the server and afterwards validate and apply the deferred updates. @@ -145,9 +181,14 @@ function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates(clientLastUpdateID)), ); - } - DeferredOnyxUpdates.getMissingOnyxUpdatesQueryPromise()?.finally(finalizeUpdatesAndResumeQueue); + return true; + }; + const shouldFinalizeAndResume = checkIfClientNeedsToBeUpdated(); + + if (shouldFinalizeAndResume) { + DeferredOnyxUpdates.getMissingOnyxUpdatesQueryPromise()?.finally(finalizeUpdatesAndResumeQueue); + } } function updateAuthTokenIfNecessary(onyxUpdatesFromServer: OnyxEntry): void { @@ -177,8 +218,8 @@ export default () => { console.debug('[OnyxUpdateManager] Listening for updates from the server'); Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_FROM_SERVER, - callback: (value) => handleOnyxUpdateGap(value), + callback: (value) => handleMissingOnyxUpdates(value), }); }; -export {handleOnyxUpdateGap, queryPromiseWrapper as queryPromise, resetDeferralLogicVariables}; +export {handleMissingOnyxUpdates, queryPromiseWrapper as queryPromise, resetDeferralLogicVariables}; diff --git a/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts b/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts index 8a7b67db30c6..8b3bf5d9af86 100644 --- a/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts @@ -1,6 +1,7 @@ import Onyx from 'react-native-onyx'; import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxUpdatesFromServer, Response} from '@src/types/onyx'; import {isValidOnyxUpdateFromServer} from '@src/types/onyx/OnyxUpdatesFromServer'; @@ -40,7 +41,7 @@ function getUpdates(options?: GetDeferredOnyxUpdatesOptiosn) { } return Object.entries(deferredUpdates).reduce((acc, [lastUpdateID, update]) => { - if (Number(lastUpdateID) > (options.minUpdateID ?? 0)) { + if (Number(lastUpdateID) > (options.minUpdateID ?? CONST.DEFAULT_NUMBER_ID)) { acc[Number(lastUpdateID)] = update; } return acc; diff --git a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts index 5cd66df6b0b0..019821c7f215 100644 --- a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts +++ b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts @@ -1,16 +1,11 @@ -import Onyx from 'react-native-onyx'; import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types'; -import ONYXKEYS from '@src/ONYXKEYS'; +import * as OnyxUpdates from '@userActions/OnyxUpdates'; 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), -}); +jest.mock('@userActions/OnyxUpdates'); type ApplyUpdatesMockValues = { - onApplyUpdates: ((updates: DeferredUpdatesDictionary) => Promise) | undefined; + beforeApplyUpdates: ((updates: DeferredUpdatesDictionary) => Promise) | undefined; }; type ApplyUpdatesMock = { @@ -19,15 +14,27 @@ type ApplyUpdatesMock = { }; const mockValues: ApplyUpdatesMockValues = { - onApplyUpdates: undefined, + beforeApplyUpdates: 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)), - ); + const createChain = () => { + let chain = Promise.resolve(); + Object.values(updates).forEach((update) => { + chain = chain.then(() => { + return OnyxUpdates.apply(update).then(() => undefined); + }); + }); + + return chain; + }; + + if (mockValuesProxy.beforeApplyUpdates === undefined) { + return createChain(); + } + + return mockValuesProxy.beforeApplyUpdates(updates).then(() => createChain()); }); export {applyUpdates, mockValuesProxy as mockValues}; diff --git a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts index f66e059ff7f6..a10c265cf569 100644 --- a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts +++ b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts @@ -6,7 +6,7 @@ import {applyUpdates} from './applyUpdates'; const UtilsImplementation = jest.requireActual('@libs/actions/OnyxUpdateManager/utils'); type OnyxUpdateManagerUtilsMockValues = { - onValidateAndApplyDeferredUpdates: ((clientLastUpdateID?: number) => Promise) | undefined; + beforeValidateAndApplyDeferredUpdates: ((clientLastUpdateID?: number) => Promise) | undefined; }; type OnyxUpdateManagerUtilsMock = typeof UtilsImplementation & { @@ -16,17 +16,19 @@ type OnyxUpdateManagerUtilsMock = typeof UtilsImplementation & { }; const mockValues: OnyxUpdateManagerUtilsMockValues = { - onValidateAndApplyDeferredUpdates: undefined, + beforeValidateAndApplyDeferredUpdates: 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), - ), -); +const validateAndApplyDeferredUpdates = jest.fn((clientLastUpdateID?: number) => { + if (mockValuesProxy.beforeValidateAndApplyDeferredUpdates === undefined) { + return UtilsImplementation.validateAndApplyDeferredUpdates(clientLastUpdateID); + } + + return mockValuesProxy.beforeValidateAndApplyDeferredUpdates(clientLastUpdateID).then(() => UtilsImplementation.validateAndApplyDeferredUpdates(clientLastUpdateID)); +}); export {applyUpdates, detectGapsAndSplit, validateAndApplyDeferredUpdates, mockValuesProxy as mockValues}; export type {OnyxUpdateManagerUtilsMock}; diff --git a/src/libs/actions/OnyxUpdateManager/utils/index.ts b/src/libs/actions/OnyxUpdateManager/utils/index.ts index 9a527308034e..bf9be862c029 100644 --- a/src/libs/actions/OnyxUpdateManager/utils/index.ts +++ b/src/libs/actions/OnyxUpdateManager/utils/index.ts @@ -2,15 +2,16 @@ import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; import * as App from '@userActions/App'; import type {DeferredUpdatesDictionary, DetectGapAndSplitResult} from '@userActions/OnyxUpdateManager/types'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {applyUpdates} from './applyUpdates'; // eslint-disable-next-line import/no-cycle import * as DeferredOnyxUpdates from './DeferredOnyxUpdates'; -let lastUpdateIDAppliedToClient = 0; +let lastUpdateIDAppliedToClient: number = CONST.DEFAULT_NUMBER_ID; Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, - callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0), + callback: (value) => (lastUpdateIDAppliedToClient = value ?? CONST.DEFAULT_NUMBER_ID), }); /** @@ -114,13 +115,13 @@ function detectGapsAndSplit(lastUpdateIDFromClient: number): DetectGapAndSplitRe * apply the updates in order after the missing updates are fetched and applied */ function validateAndApplyDeferredUpdates(clientLastUpdateID?: number, previousParams?: {newLastUpdateIDFromClient: number; latestMissingUpdateID: number}): Promise { - const lastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0; + const lastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? CONST.DEFAULT_NUMBER_ID; Log.info('[DeferredUpdates] Processing deferred updates', false, {lastUpdateIDFromClient, previousParams}); const {applicableUpdates, updatesAfterGaps, latestMissingUpdateID} = detectGapsAndSplit(lastUpdateIDFromClient); - // If there are no applicable deferred updates and no missing deferred updates, + // If there are no applicably deferred updates and no missing deferred updates, // we don't need to apply or re-fetch any updates. We can just unpause the queue by resolving. if (Object.values(applicableUpdates).length === 0 && latestMissingUpdateID === undefined) { return Promise.resolve(); @@ -138,13 +139,13 @@ function validateAndApplyDeferredUpdates(clientLastUpdateID?: number, previousPa // 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. + // as long as there was no new gap detected. Otherwise, repeat the process. - const newLastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0; + const newLastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? CONST.DEFAULT_NUMBER_ID; DeferredOnyxUpdates.enqueue(updatesAfterGaps, {shouldPauseSequentialQueue: false}); - // If lastUpdateIDAppliedToClient got updated, we will just retrigger the validation + // If lastUpdateIDAppliedToClient got updated, we will just re-trigger the validation // and application of the current deferred updates. if (latestMissingUpdateID <= newLastUpdateIDFromClient) { validateAndApplyDeferredUpdates(undefined, {newLastUpdateIDFromClient, latestMissingUpdateID}) diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index 52dfa9dfd742..a09159993ad8 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -2,7 +2,6 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {Merge} from 'type-fest'; import Log from '@libs/Log'; -import * as SequentialQueue from '@libs/Network/SequentialQueue'; import Performance from '@libs/Performance'; import PusherUtils from '@libs/PusherUtils'; import CONST from '@src/CONST'; @@ -154,28 +153,28 @@ function apply({lastUpdateID, type, request, response, updates}: OnyxUpdatesFrom * @param [updateParams.updates] Exists if updateParams.type === 'pusher' */ function saveUpdateInformation(updateParams: OnyxUpdatesFromServer) { - // If we got here, that means we are missing some updates on our local storage. To - // guarantee that we're not fetching more updates before our local data is up to date, - // let's stop the sequential queue from running until we're done catching up. - SequentialQueue.pause(); - // Always use set() here so that the updateParams are never merged and always unique to the request that came in Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, updateParams); } +type DoesClientNeedToBeUpdatedParams = { + clientLastUpdateID?: number; + previousUpdateID?: number; +}; + /** * This function will receive the previousUpdateID from any request/pusher update that has it, compare to our current app state * and return if an update is needed * @param previousUpdateID The previousUpdateID contained in the response object * @param clientLastUpdateID an optional override for the lastUpdateIDAppliedToClient */ -function doesClientNeedToBeUpdated(previousUpdateID = 0, clientLastUpdateID = 0): boolean { +function doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID}: DoesClientNeedToBeUpdatedParams): boolean { // If no previousUpdateID is sent, this is not a WRITE request so we don't need to update our current state if (!previousUpdateID) { return false; } - const lastUpdateIDFromClient = clientLastUpdateID || lastUpdateIDAppliedToClient; + const lastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient; // If we don't have any value in lastUpdateIDFromClient, this is the first time we're receiving anything, so we need to do a last reconnectApp if (!lastUpdateIDFromClient) { @@ -191,4 +190,5 @@ function doesClientNeedToBeUpdated(previousUpdateID = 0, clientLastUpdateID = 0) } // eslint-disable-next-line import/prefer-default-export -export {apply, doesClientNeedToBeUpdated, saveUpdateInformation}; +export {apply, doesClientNeedToBeUpdated, saveUpdateInformation, applyHTTPSOnyxUpdates as INTERNAL_DO_NOT_USE_applyHTTPSOnyxUpdates}; +export type {DoesClientNeedToBeUpdatedParams as ManualOnyxUpdateCheckIds}; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 71973a5adbc3..80d04a4617bd 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -58,7 +58,7 @@ let currentEmail = ''; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (value) => { - currentUserAccountID = value?.accountID ?? -1; + currentUserAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID; currentEmail = value?.email ?? ''; }, }); @@ -910,9 +910,9 @@ function subscribeToUserEvents() { // Example: {lastUpdateID: 1, previousUpdateID: 0, updates: [{onyxMethod: 'whatever', key: 'foo', value: 'bar'}]} const updates = { type: CONST.ONYX_UPDATE_TYPES.PUSHER, - lastUpdateID: Number(pushJSON.lastUpdateID || 0), + lastUpdateID: Number(pushJSON.lastUpdateID ?? CONST.DEFAULT_NUMBER_ID), updates: pushJSON.updates ?? [], - previousUpdateID: Number(pushJSON.previousUpdateID || 0), + previousUpdateID: Number(pushJSON.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID), }; applyOnyxUpdatesReliably(updates); }); diff --git a/src/libs/actions/__mocks__/App.ts b/src/libs/actions/__mocks__/App.ts index 09fd553a87f3..0b2b098feefb 100644 --- a/src/libs/actions/__mocks__/App.ts +++ b/src/libs/actions/__mocks__/App.ts @@ -1,10 +1,11 @@ -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 * as OnyxUpdates from '@userActions/OnyxUpdates'; import type {OnyxUpdatesFromServer} from '@src/types/onyx'; import createProxyForObject from '@src/utils/createProxyForObject'; +jest.mock('@libs/actions/OnyxUpdates'); +jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); + const AppImplementation = jest.requireActual('@libs/actions/App'); const { setLocale, @@ -39,13 +40,30 @@ const mockValues: AppMockValues = { }; const mockValuesProxy = createProxyForObject(mockValues); -const ApplyUpdatesImplementation = 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); +const getMissingOnyxUpdates = jest.fn((updateIDFrom: number, updateIDTo: number) => { + const updates = mockValuesProxy.missingOnyxUpdatesToBeApplied ?? []; + if (updates.length === 0) { + for (let i = updateIDFrom + 1; i <= updateIDTo; i++) { + updates.push({ + lastUpdateID: i, + previousUpdateID: i - 1, + } as OnyxUpdatesFromServer); + } } - return ApplyUpdatesImplementation.applyUpdates(mockValuesProxy.missingOnyxUpdatesToBeApplied); + let chain = Promise.resolve(); + updates.forEach((update) => { + chain = chain.then(() => { + if (!OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: Number(update.previousUpdateID)})) { + return OnyxUpdates.apply(update).then(() => undefined); + } + + OnyxUpdates.saveUpdateInformation(update); + return Promise.resolve(); + }); + }); + + return chain; }); export { diff --git a/src/libs/actions/__mocks__/OnyxUpdates.ts b/src/libs/actions/__mocks__/OnyxUpdates.ts new file mode 100644 index 000000000000..3e4cb10d7f9e --- /dev/null +++ b/src/libs/actions/__mocks__/OnyxUpdates.ts @@ -0,0 +1,44 @@ +import Onyx from 'react-native-onyx'; +import type * as OnyxUpdatesImport from '@userActions/OnyxUpdates'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxUpdatesFromServer, Response} from '@src/types/onyx'; + +jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); + +const OnyxUpdatesImplementation = jest.requireActual('@libs/actions/OnyxUpdates'); +const {doesClientNeedToBeUpdated, saveUpdateInformation, INTERNAL_DO_NOT_USE_applyHTTPSOnyxUpdates: applyHTTPSOnyxUpdates} = OnyxUpdatesImplementation; + +type OnyxUpdatesMock = typeof OnyxUpdatesImport & { + apply: jest.Mock, [OnyxUpdatesFromServer]>; +}; + +let lastUpdateIDAppliedToClient: number | undefined = 0; +Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (val) => (lastUpdateIDAppliedToClient = val), +}); + +const apply = jest.fn(({lastUpdateID, request, response}: OnyxUpdatesFromServer): Promise | undefined => { + if (lastUpdateID && (lastUpdateIDAppliedToClient === undefined || Number(lastUpdateID) > lastUpdateIDAppliedToClient)) { + Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, Number(lastUpdateID)); + } + + if (request && response) { + return applyHTTPSOnyxUpdates(request, response).then(() => undefined); + } + + return Promise.resolve(); +}); + +export { + // Mocks + apply, + + // Actual OnyxUpdates implementation + doesClientNeedToBeUpdated, + saveUpdateInformation, +}; + +type ManualOnyxUpdateCheckIds = OnyxUpdatesImport.ManualOnyxUpdateCheckIds; +export type {ManualOnyxUpdateCheckIds}; +export type {OnyxUpdatesMock}; diff --git a/src/libs/actions/applyOnyxUpdatesReliably.ts b/src/libs/actions/applyOnyxUpdatesReliably.ts index 17754712cdc8..d8475c55042a 100644 --- a/src/libs/actions/applyOnyxUpdatesReliably.ts +++ b/src/libs/actions/applyOnyxUpdatesReliably.ts @@ -1,7 +1,14 @@ +import * as SequentialQueue from '@libs/Network/SequentialQueue'; +import CONST from '@src/CONST'; import type {OnyxUpdatesFromServer} from '@src/types/onyx'; -import {handleOnyxUpdateGap} from './OnyxUpdateManager'; +import {handleMissingOnyxUpdates} from './OnyxUpdateManager'; import * as OnyxUpdates from './OnyxUpdates'; +type ApplyOnyxUpdatesReliablyOptions = { + clientLastUpdateID?: number; + shouldRunSync?: boolean; +}; + /** * Checks for and handles gaps of onyx updates between the client and the given server updates before applying them * @@ -11,16 +18,32 @@ import * as OnyxUpdates from './OnyxUpdates'; * @param shouldRunSync * @returns */ -export default function applyOnyxUpdatesReliably(updates: OnyxUpdatesFromServer, shouldRunSync = false, clientLastUpdateID = 0) { - const previousUpdateID = Number(updates.previousUpdateID) || 0; - if (!OnyxUpdates.doesClientNeedToBeUpdated(previousUpdateID, clientLastUpdateID)) { - OnyxUpdates.apply(updates); +export default function applyOnyxUpdatesReliably(updates: OnyxUpdatesFromServer, {shouldRunSync = false, clientLastUpdateID}: ApplyOnyxUpdatesReliablyOptions = {}) { + const fetchMissingUpdates = () => { + // If we got here, that means we are missing some updates on our local storage. To + // guarantee that we're not fetching more updates before our local data is up to date, + // let's stop the sequential queue from running until we're done catching up. + SequentialQueue.pause(); + + if (shouldRunSync) { + handleMissingOnyxUpdates(updates, clientLastUpdateID); + } else { + OnyxUpdates.saveUpdateInformation(updates); + } + }; + + // If a pendingLastUpdateID is was provided, it means that the backend didn't send updates because the payload was too big. + // In this case, we need to fetch the missing updates up to the pendingLastUpdateID. + if (updates.shouldFetchPendingUpdates) { + fetchMissingUpdates(); return; } - if (shouldRunSync) { - handleOnyxUpdateGap(updates, clientLastUpdateID); - } else { - OnyxUpdates.saveUpdateInformation(updates); + const previousUpdateID = Number(updates.previousUpdateID) ?? CONST.DEFAULT_NUMBER_ID; + if (!OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID})) { + OnyxUpdates.apply(updates); + return; } + + fetchMissingUpdates(); } diff --git a/src/types/onyx/OnyxUpdatesFromServer.ts b/src/types/onyx/OnyxUpdatesFromServer.ts index 99d565ede08f..ef82ab013e39 100644 --- a/src/types/onyx/OnyxUpdatesFromServer.ts +++ b/src/types/onyx/OnyxUpdatesFromServer.ts @@ -30,7 +30,10 @@ type OnyxUpdatesFromServer = { lastUpdateID: number | string; /** Previous update ID from server */ - previousUpdateID: number | string; + previousUpdateID?: number | string; + + /** Whether the client should fetch pending updates from the server */ + shouldFetchPendingUpdates?: boolean; /** Request data sent to the server */ request?: Request; diff --git a/tests/actions/OnyxUpdateManagerTest.ts b/tests/actions/OnyxUpdateManagerTest.ts index e1008064163b..7366c13fa8b3 100644 --- a/tests/actions/OnyxUpdateManagerTest.ts +++ b/tests/actions/OnyxUpdateManagerTest.ts @@ -15,12 +15,13 @@ 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'; +import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; -jest.mock('@libs/actions/App'); -jest.mock('@libs/actions/OnyxUpdateManager/utils'); -jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates', () => { - const ApplyUpdatesImplementation = jest.requireActual('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); +jest.mock('@userActions/OnyxUpdates'); +jest.mock('@userActions/App'); +jest.mock('@userActions/OnyxUpdateManager/utils'); +jest.mock('@userActions/OnyxUpdateManager/utils/applyUpdates', () => { + const ApplyUpdatesImplementation = jest.requireActual('@userActions/OnyxUpdateManager/utils/applyUpdates'); return { applyUpdates: jest.fn((updates: DeferredUpdatesDictionary) => ApplyUpdatesImplementation.applyUpdates(updates)), @@ -53,14 +54,14 @@ const exampleReportAction = { const initialData = {report1: exampleReportAction, report2: exampleReportAction, report3: exampleReportAction} as unknown as OnyxTypes.ReportActions; -const mockUpdate1 = createOnyxMockUpdate(1, [ +const mockUpdate1 = OnyxUpdateMockUtils.createUpdate(1, [ { onyxMethod: OnyxUtils.METHOD.SET, key: ONYX_KEY, value: initialData, }, ]); -const mockUpdate2 = createOnyxMockUpdate(2, [ +const mockUpdate2 = OnyxUpdateMockUtils.createUpdate(2, [ { onyxMethod: OnyxUtils.METHOD.MERGE, key: ONYX_KEY, @@ -79,7 +80,7 @@ const report2PersonDiff = { const report3AvatarDiff: Partial = { avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_5.png', }; -const mockUpdate3 = createOnyxMockUpdate(3, [ +const mockUpdate3 = OnyxUpdateMockUtils.createUpdate(3, [ { onyxMethod: OnyxUtils.METHOD.MERGE, key: ONYX_KEY, @@ -89,7 +90,7 @@ const mockUpdate3 = createOnyxMockUpdate(3, [ }, }, ]); -const mockUpdate4 = createOnyxMockUpdate(4, [ +const mockUpdate4 = OnyxUpdateMockUtils.createUpdate(4, [ { onyxMethod: OnyxUtils.METHOD.MERGE, key: ONYX_KEY, @@ -106,7 +107,7 @@ const report4 = { ...exampleReportAction, automatic: true, } satisfies Partial; -const mockUpdate5 = createOnyxMockUpdate(5, [ +const mockUpdate5 = OnyxUpdateMockUtils.createUpdate(5, [ { onyxMethod: OnyxUtils.METHOD.MERGE, key: ONYX_KEY, @@ -135,7 +136,7 @@ describe('actions/OnyxUpdateManager', () => { await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 1); await Onyx.set(ONYX_KEY, initialData); - OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = undefined; + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = undefined; App.mockValues.missingOnyxUpdatesToBeApplied = undefined; OnyxUpdateManagerExports.resetDeferralLogicVariables(); }); @@ -194,7 +195,7 @@ describe('actions/OnyxUpdateManager', () => { finishFirstCall = resolve; }); - OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = () => { + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = () => { // 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]; @@ -261,7 +262,7 @@ describe('actions/OnyxUpdateManager', () => { }; let firstCallFinished = false; - OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = () => { + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = () => { if (firstCallFinished) { assertAfterSecondGetMissingOnyxUpdates(); return Promise.resolve(); diff --git a/tests/unit/OnyxUpdateManagerTest.ts b/tests/unit/OnyxUpdateManagerTest.ts index 7b0446641458..cd0922416a62 100644 --- a/tests/unit/OnyxUpdateManagerTest.ts +++ b/tests/unit/OnyxUpdateManagerTest.ts @@ -1,17 +1,20 @@ 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 type {AppActionsMock} from '@userActions/__mocks__/App'; +import type {OnyxUpdatesMock} from '@userActions/__mocks__/OnyxUpdates'; +import * as AppImport from '@userActions/App'; +import * as OnyxUpdateManager from '@userActions/OnyxUpdateManager'; +import * as OnyxUpdateManagerUtilsImport from '@userActions/OnyxUpdateManager/utils'; +import type {OnyxUpdateManagerUtilsMock} from '@userActions/OnyxUpdateManager/utils/__mocks__'; +import type {ApplyUpdatesMock} from '@userActions/OnyxUpdateManager/utils/__mocks__/applyUpdates'; +import * as ApplyUpdatesImport from '@userActions/OnyxUpdateManager/utils/applyUpdates'; +import * as OnyxUpdatesImport from '@userActions/OnyxUpdates'; import ONYXKEYS from '@src/ONYXKEYS'; -import createOnyxMockUpdate from '../utils/createOnyxMockUpdate'; +import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; -jest.mock('@libs/actions/App'); -jest.mock('@libs/actions/OnyxUpdateManager/utils'); -jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); +jest.mock('@userActions/OnyxUpdates'); +jest.mock('@userActions/App'); +jest.mock('@userActions/OnyxUpdateManager/utils'); +jest.mock('@userActions/OnyxUpdateManager/utils/applyUpdates'); jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ default: () => ({ @@ -19,17 +22,23 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ }), })); +const OnyxUpdates = OnyxUpdatesImport as OnyxUpdatesMock; const App = AppImport as AppActionsMock; const ApplyUpdates = ApplyUpdatesImport as ApplyUpdatesMock; const OnyxUpdateManagerUtils = OnyxUpdateManagerUtilsImport as OnyxUpdateManagerUtilsMock; -const mockUpdate3 = createOnyxMockUpdate(3); -const offsetedMockUpdate3 = createOnyxMockUpdate(3, undefined, 2); -const mockUpdate4 = createOnyxMockUpdate(4); -const mockUpdate5 = createOnyxMockUpdate(5); -const mockUpdate6 = createOnyxMockUpdate(6); -const mockUpdate7 = createOnyxMockUpdate(7); -const mockUpdate8 = createOnyxMockUpdate(8); +const update2 = OnyxUpdateMockUtils.createUpdate(2); +const pendingUpdateUpTo2 = OnyxUpdateMockUtils.createPendingUpdate(2); + +const update3 = OnyxUpdateMockUtils.createUpdate(3); +const pendingUpdateUpTo3 = OnyxUpdateMockUtils.createPendingUpdate(3); +const offsetUpdate3 = OnyxUpdateMockUtils.createUpdate(3, undefined, 1); + +const update4 = OnyxUpdateMockUtils.createUpdate(4); +const update5 = OnyxUpdateMockUtils.createUpdate(5); +const update6 = OnyxUpdateMockUtils.createUpdate(6); +const update7 = OnyxUpdateMockUtils.createUpdate(7); +const update8 = OnyxUpdateMockUtils.createUpdate(8); describe('OnyxUpdateManager', () => { let lastUpdateIDAppliedToClient = 1; @@ -43,20 +52,26 @@ describe('OnyxUpdateManager', () => { 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; + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = undefined; + ApplyUpdates.mockValues.beforeApplyUpdates = undefined; + App.mockValues.missingOnyxUpdatesToBeApplied = 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); + OnyxUpdateManager.handleMissingOnyxUpdates(update3); + OnyxUpdateManager.handleMissingOnyxUpdates(update4); + OnyxUpdateManager.handleMissingOnyxUpdates(update5); return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(5); + // OnyxUpdates.apply should have been called 4 times, + // - once for the fetched missing update (2) + // - three times for the deferred updates (3-5) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(4); + // 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); @@ -70,27 +85,32 @@ describe('OnyxUpdateManager', () => { // 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}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: update3, 4: update4, 5: update5}); }); }); - 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); + it('should only apply deferred updates that are newer than the last locally applied update (pending deferred updates)', async () => { + OnyxUpdateManager.handleMissingOnyxUpdates(update4); + OnyxUpdateManager.handleMissingOnyxUpdates(update5); + OnyxUpdateManager.handleMissingOnyxUpdates(update6); - OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = async () => { + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = 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; + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = undefined; }; return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(6); + // OnyxUpdates.apply should have been called 2 times, + // - twice for the fetched missing updates (2-3) + // - once for the deferred update (6) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(3); + // 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); @@ -108,19 +128,25 @@ describe('OnyxUpdateManager', () => { expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenCalledWith({6: mockUpdate6}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {6: update6}); }); }); it('should re-fetch missing updates if the deferred updates have a gap', async () => { - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate5); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate6); + OnyxUpdateManager.handleMissingOnyxUpdates(update3); + OnyxUpdateManager.handleMissingOnyxUpdates(update5); + OnyxUpdateManager.handleMissingOnyxUpdates(update6); return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(6); + // OnyxUpdates.apply should have been called 5 times, + // - once for the first fetched missing update (2) + // - once for the second fetched missing update (4) + // - three times for the deferred updates (3, 5, 6) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(5); + // 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); @@ -136,27 +162,34 @@ describe('OnyxUpdateManager', () => { // 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}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: update3}); // 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}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {5: update5, 6: update6}); }); }); it('should re-fetch missing deferred updates only once per batch', async () => { - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate4); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate6); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate8); + OnyxUpdateManager.handleMissingOnyxUpdates(update3); + OnyxUpdateManager.handleMissingOnyxUpdates(update4); + OnyxUpdateManager.handleMissingOnyxUpdates(update6); + OnyxUpdateManager.handleMissingOnyxUpdates(update8); return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(8); + // OnyxUpdates.apply should have been called 7 times, + // - once for the first fetched missing update (2) + // - once for the second fetched missing update (5) + // - once for the third fetched missing update (7) + // - four times for the deferred updates (3, 4, 6, 8) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(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); expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(2); @@ -167,36 +200,42 @@ describe('OnyxUpdateManager', () => { // 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}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: update3, 4: update4}); // 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}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {8: update8}); }); }); 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); + OnyxUpdateManager.handleMissingOnyxUpdates(update3); + OnyxUpdateManager.handleMissingOnyxUpdates(update5); + OnyxUpdateManager.handleMissingOnyxUpdates(update6); + OnyxUpdateManager.handleMissingOnyxUpdates(update7); - ApplyUpdates.mockValues.onApplyUpdates = async () => { + ApplyUpdates.mockValues.beforeApplyUpdates = 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; + ApplyUpdates.mockValues.beforeApplyUpdates = undefined; }; return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(7); + // OnyxUpdates.apply should have been called 4 times, + // - once for the first fetched missing update (2) + // - updates 3-5 are not fetched, because they are set externally + // - two times for the remaining deferred updates (6, 7) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(4); + // 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); @@ -211,31 +250,38 @@ describe('OnyxUpdateManager', () => { // 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}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: update3}); // 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}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {6: update6, 7: update7}); }); }); 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); + OnyxUpdateManager.handleMissingOnyxUpdates(update3); + OnyxUpdateManager.handleMissingOnyxUpdates(update7); - ApplyUpdates.mockValues.onApplyUpdates = async () => { + ApplyUpdates.mockValues.beforeApplyUpdates = 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; + ApplyUpdates.mockValues.beforeApplyUpdates = undefined; }; return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(7); + // OnyxUpdates.apply should have been called 6 times, + // - once for the first fetched missing update (2) + // - updates 3-4 are not fetched, because they are set externally + // - three times for the second fetched missing updates (5-6) + // - once for the remaining deferred update (7) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(5); + // 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); @@ -250,26 +296,32 @@ describe('OnyxUpdateManager', () => { // 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}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: update3}); // 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). + // 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}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {7: update7}); }); }); it('should only fetch missing updates that are not outdated (older than already locally applied update)', () => { - OnyxUpdateManager.handleOnyxUpdateGap(offsetedMockUpdate3); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate4); + OnyxUpdateManager.handleMissingOnyxUpdates(offsetUpdate3); + OnyxUpdateManager.handleMissingOnyxUpdates(update4); return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 4. expect(lastUpdateIDAppliedToClient).toBe(4); + // OnyxUpdates.apply should have been called 2 times, + // - once for the first fetched missing update (2) + // - the offset update is omitted + // - three times for the deferred update (4) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(2); + // validateAndApplyDeferredUpdates should be called once for the initial deferred updates // Unfortunately, we cannot easily count the calls of this function with Jest, since it recursively calls itself. // The intended assertion would look like this: @@ -279,7 +331,89 @@ describe('OnyxUpdateManager', () => { // 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: offsetedMockUpdate3, 4: mockUpdate4}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: offsetUpdate3, 4: update4}); + + // There are no gaps in the deferred updates, therefore only one call to getMissingOnyxUpdates should be triggered + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + }); + }); + + it('should fetch a single pending update if `hasPendingOnyxUpdates flag is true`', () => { + App.mockValues.missingOnyxUpdatesToBeApplied = [update2]; + + OnyxUpdateManager.handleMissingOnyxUpdates(pendingUpdateUpTo2); + + return OnyxUpdateManager.queryPromise.then(() => { + // After all the pending update has been applied, the lastUpdateIDAppliedToClient should be 2. + expect(lastUpdateIDAppliedToClient).toBe(2); + + // OnyxUpdates.apply should have been called 1 times, + // - once for the pending update (2) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(1); + + // validateAndApplyDeferredUpdates should be called once for the initial deferred updates + // 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 no call to applyUpdates, because there are no deferred updates + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(0); + + // There are no deferred updates, so this should only be called once for the pending update. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + }); + }); + + it('should fetch multiple pending updates if `hasPendingOnyxUpdates flag is true`', () => { + App.mockValues.missingOnyxUpdatesToBeApplied = [update2, update3]; + + OnyxUpdateManager.handleMissingOnyxUpdates(pendingUpdateUpTo3); + + return OnyxUpdateManager.queryPromise.then(() => { + // After all the pending update has been applied, the lastUpdateIDAppliedToClient should be 3. + expect(lastUpdateIDAppliedToClient).toBe(3); + + // OnyxUpdates.apply should have been called 2 times, + // - twice for the pending updates (2, 3) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(2); + + // validateAndApplyDeferredUpdates should be called once for the initial deferred updates + // 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 no call to applyUpdates, because there are no deferred updates + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(0); + + // There are no deferred updates, so this should only be called once for the pending update. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + }); + }); + + it('should apply deferred updates after fetching pending updates', () => { + App.mockValues.missingOnyxUpdatesToBeApplied = [update2, update3]; + + OnyxUpdateManager.handleMissingOnyxUpdates(pendingUpdateUpTo3); + OnyxUpdateManager.handleMissingOnyxUpdates(update4); + + return OnyxUpdateManager.queryPromise.then(() => { + // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 4. + expect(lastUpdateIDAppliedToClient).toBe(4); + + // OnyxUpdates.apply should have been called 3 times, + // - twice for the pending updates (2, 3) + // - once for the deferred update (4) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(3); + + // validateAndApplyDeferredUpdates should be called once for the initial deferred updates + // 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 the deferred updates. + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {4: update4}); // There are no gaps in the deferred updates, therefore only one call to getMissingOnyxUpdates should be triggered expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); diff --git a/tests/utils/OnyxUpdateMockUtils.ts b/tests/utils/OnyxUpdateMockUtils.ts new file mode 100644 index 000000000000..90d738a413cc --- /dev/null +++ b/tests/utils/OnyxUpdateMockUtils.ts @@ -0,0 +1,36 @@ +import type {OnyxUpdate} from 'react-native-onyx'; +import CONST from '@src/CONST'; +import type {OnyxUpdatesFromServer} from '@src/types/onyx'; + +const createUpdate = (lastUpdateID: number, successData: OnyxUpdate[] = [], previousUpdateID?: number): OnyxUpdatesFromServer => ({ + type: CONST.ONYX_UPDATE_TYPES.HTTPS, + lastUpdateID, + previousUpdateID: previousUpdateID ?? lastUpdateID - 1, + request: { + command: 'TestCommand', + successData, + failureData: [], + finallyData: [], + optimisticData: [], + }, + response: { + jsonCode: 200, + lastUpdateID, + previousUpdateID: previousUpdateID ?? lastUpdateID - 1, + onyxData: successData, + }, +}); + +const createPendingUpdate = (lastUpdateID: number): OnyxUpdatesFromServer => ({ + type: CONST.ONYX_UPDATE_TYPES.AIRSHIP, + lastUpdateID, + shouldFetchPendingUpdates: true, + updates: [], +}); + +const OnyxUpdateMockUtils = { + createUpdate, + createPendingUpdate, +}; + +export default OnyxUpdateMockUtils; diff --git a/tests/utils/createOnyxMockUpdate.ts b/tests/utils/createOnyxMockUpdate.ts deleted file mode 100644 index e50918b01faf..000000000000 --- a/tests/utils/createOnyxMockUpdate.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type {OnyxUpdate} from 'react-native-onyx'; -import type {OnyxUpdatesFromServer} from '@src/types/onyx'; - -const createOnyxMockUpdate = (lastUpdateID: number, successData: OnyxUpdate[] = [], previousUpdateIDOffset = 1): OnyxUpdatesFromServer => ({ - type: 'https', - lastUpdateID, - previousUpdateID: lastUpdateID - previousUpdateIDOffset, - request: { - command: 'TestCommand', - successData, - failureData: [], - finallyData: [], - optimisticData: [], - }, - response: { - jsonCode: 200, - lastUpdateID, - previousUpdateID: lastUpdateID - previousUpdateIDOffset, - onyxData: successData, - }, -}); - -export default createOnyxMockUpdate;