Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Only fetch report history we need on reconnect + fix sign out issue #2616

Merged
merged 14 commits into from
May 3, 2021
8 changes: 8 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,14 @@ const CONST = {
STAGING: 'STG',
PRODUCTION: 'PROD',
},

// Used to delay the initial fetching of reportActions when the app first inits or reconnects (e.g. returning
// from backgound). The times are based on how long it generally seems to take for the app to become interactive
// in each scenario.
FETCH_ACTIONS_DELAY: {
STARTUP: 8000,
marcaaron marked this conversation as resolved.
Show resolved Hide resolved
RECONNECT: 1000,
},
};

export default CONST;
18 changes: 15 additions & 3 deletions src/libs/CollectionUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@ import _ from 'underscore';
* e.g. {1: '1', 2: '2', 3: '3'} -> '3'
*
* @param {Object} object
* @return {*}
* @returns {*}
*/
export function lastItem(object = {}) {
function lastItem(object = {}) {
marcaaron marked this conversation as resolved.
Show resolved Hide resolved
const lastKey = _.last(_.keys(object)) || 0;
return object[lastKey];
}

export default {
/**
* Used to grab the id for a particular collection item's key.
* e.g. reportActions_1 -> 1
*
* @param {String} key
* @returns {String}
*/
function extractCollectionItemID(key) {
return key.split('_')[1];
}

export {
lastItem,
extractCollectionItemID,
};
5 changes: 3 additions & 2 deletions src/libs/Navigation/AppNavigator/AuthScreens.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
SettingsModalStackNavigator,
} from './ModalStackNavigators';
import SCREENS from '../../../SCREENS';
import Timers from '../../Timers';

Onyx.connect({
key: ONYXKEYS.MY_PERSONAL_DETAILS,
Expand Down Expand Up @@ -120,14 +121,14 @@ class AuthScreens extends React.Component {
// Refresh the personal details, timezone and betas every 30 minutes
// There is no pusher event that sends updated personal details data yet
// See https://github.com/Expensify/ReactNativeChat/issues/468
this.interval = setInterval(() => {
this.interval = Timers.register(setInterval(() => {
if (this.props.network.isOffline) {
return;
}
PersonalDetails.fetch();
User.getUserDetails();
User.getBetas();
}, 1000 * 60 * 30);
}, 1000 * 60 * 30));

Timing.end(CONST.TIMING.HOMEPAGE_INITIAL_RENDER);

Expand Down
31 changes: 31 additions & 0 deletions src/libs/Timers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import _ from 'underscore';

const timers = [];

/**
* Register a timer so it can be cleaned up later.
*
* @param {Number} timerID
* @returns {Number}
*/
function register(timerID) {
timers.push(timerID);
return timerID;
}

/**
* Clears all timers that we have registered. Use for long running tasks that may begin once logged out.
*/
function clearAll() {
_.each(timers, (timer) => {
// We don't know whether it's a setTimeout or a setInterval, but it doesn't really matter. If the id doesn't
// exist nothing bad happens.
clearTimeout(timer);
clearInterval(timer);
});
}
Comment on lines +19 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const timers = []; is never emptied

clearAll will be foreach-ing already cleared timers

Maybe it can be refactored to something like

const timers = {};

function clearSingle(id) {
  clearTimeout(id);
  clearInterval(id);
  delete timers[id];
}

function clearAll() {
  _.each(timers, clearSingle);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. But there is no danger in clearing already cleared timers so this might never be a problem.


export default {
register,
clearAll,
};
2 changes: 1 addition & 1 deletion src/libs/actions/PersonalDetails.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function getDisplayName(login, personalDetail) {
// If we have a number like [email protected] then let's remove @expensify.sms
// so that the option looks cleaner in our UI.
const userLogin = Str.removeSMSDomain(login);
const userDetails = personalDetail || personalDetails[login];
const userDetails = personalDetail || lodashGet(personalDetails, login);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor fix here to get rid of a propTypes warning


if (!userDetails) {
return userLogin;
Expand Down
71 changes: 51 additions & 20 deletions src/libs/actions/Report.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import * as API from '../API';
import CONST from '../../CONST';
import Log from '../Log';
import {isReportMessageAttachment} from '../reportUtils';
import Timers from '../Timers';
import {dangerouslyGetReportActionsMaxSequenceNumber, isReportMissingActions} from './ReportActions';

let currentUserEmail;
let currentUserAccountID;
Expand Down Expand Up @@ -56,7 +58,19 @@ Onyx.connect({

const typingWatchTimers = {};

// Keeps track of the max sequence number for each report
/**
* Map of the most recent sequenceNumber for a reports_* key in Onyx by reportID.
*
* There are several sources that can set the most recent reportAction's sequenceNumber for a report:
*
* - Fetching the report object
* - Fetching the report history
* - Optimistically creating a report action
* - Handling a report action via Pusher
*
* Those values are stored in reportMaxSequenceNumbers and treated as the main source of truth for each report's max
* sequenceNumber.
*/
const reportMaxSequenceNumbers = {};

// Keeps track of the last read for each report
Expand Down Expand Up @@ -268,7 +282,7 @@ function fetchIOUReportID(debtorEmail) {
* chat report IDs
*
* @param {Array} chatList
* @return {Promise} only used internally when fetchAllReports() is called
* @returns {Promise<Number[]>} only used internally when fetchAllReports() is called
*/
function fetchChatReportsByIDs(chatList) {
let fetchedReports;
Expand Down Expand Up @@ -677,7 +691,7 @@ function unsubscribeFromReportChannel(reportID) {
*
* @param {String[]} participants
* @param {Boolean} shouldNavigate
* @returns {Promise}
* @returns {Promise<Number[]>}
*/
function fetchOrCreateChatReport(participants, shouldNavigate = true) {
if (participants.length < 2) {
Expand All @@ -700,7 +714,10 @@ function fetchOrCreateChatReport(participants, shouldNavigate = true) {
// Redirect the logged in person to the new report
Navigation.navigate(ROUTES.getReportRoute(data.reportID));
}
return data.reportID;

// We are returning an array with the reportID here since fetchAllReports calls this method or
// fetchChatReportsByIDs which returns an array of reportIDs.
return [data.reportID];
marcaaron marked this conversation as resolved.
Show resolved Hide resolved
});
}

Expand Down Expand Up @@ -753,8 +770,6 @@ function fetchAllReports(
shouldRecordHomePageTiming = false,
shouldDelayActionsFetch = false,
) {
let reportIDs = [];

API.Get({
returnValueList: 'chatList',
})
Expand All @@ -764,7 +779,7 @@ function fetchAllReports(
}

// The cast here is necessary as Get rvl='chatList' may return an int or Array
reportIDs = String(response.chatList)
const reportIDs = String(response.chatList)
.split(',')
.filter(_.identity);

Expand All @@ -773,28 +788,44 @@ function fetchAllReports(
return fetchChatReportsByIDs(reportIDs);
}

return fetchOrCreateChatReport([currentUserEmail, '[email protected]'], false)
.then((createdReportID) => {
reportIDs = [createdReportID];
});
return fetchOrCreateChatReport([currentUserEmail, '[email protected]'], false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of modifying fetchOrCreateChatReport you could just return the array like:

return fetchOrCreateChatReport([currentUserEmail, '[email protected]'], false)
  .then(reportId => [reportId]); 

IMO it's better than having to justify something fishy with a comment

            // We are returning an array with the reportID here since fetchAllReports calls this method or
            // fetchChatReportsByIDs which returns an array of reportIDs.
            return [data.reportID];
  • this just discloses a coupling to the fetchAllReports usage

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your notes here. I see no major problem with doing it either way.

})
.then(() => {
.then((returnedReportIDs) => {
Onyx.set(ONYXKEYS.INITIAL_REPORT_DATA_LOADED, true);

if (shouldRecordHomePageTiming) {
Timing.end(CONST.TIMING.HOMEPAGE_REPORTS_LOADED);
}

// Optionally delay fetching report history as it significantly increases sign in to interactive time
_.delay(() => {
Log.info('[Report] Fetching report actions for reports', true, {reportIDs});
_.each(reportIDs, (reportID) => {
fetchActions(reportID);
// Delay fetching report history as it significantly increases sign in to interactive time.
// Register the timer so we can clean it up if the user quickly logs out after logging in. If we don't
// cancel the timer we'll make unnecessary API requests from the sign in page.
Timers.register(setTimeout(() => {
// Filter reports to see which ones have actions we need to fetch so we can preload Onyx with new
// content and improve chat switching experience by only downloading content we don't have yet.
// This improves performance significantly when reconnecting by limiting API requests and unnecessary
// data processing by Onyx.
marcaaron marked this conversation as resolved.
Show resolved Hide resolved
const reportIDsToFetchActions = _.filter(returnedReportIDs, id => (
isReportMissingActions(id, reportMaxSequenceNumbers[id])
));

if (_.isEmpty(reportIDsToFetchActions)) {
console.debug('[Report] Local reportActions up to date. Not fetching additional actions.');
return;
}

console.debug('[Report] Fetching reportActions for reportIDs: ', {
reportIDs: reportIDsToFetchActions,
});
_.each(reportIDsToFetchActions, (reportID) => {
const offset = dangerouslyGetReportActionsMaxSequenceNumber(reportID, false);
fetchActions(reportID, offset);
});

// We are waiting 8 seconds since this provides a good time window to allow the UI to finish loading before
// bogging it down with more requests and operations.
}, shouldDelayActionsFetch ? 8000 : 0);
// We are waiting a set amount of time to allow the UI to finish loading before bogging it down with
// more requests and operations. Startup delay is longer since there is a lot more work done to build
// up the UI when the app first initializes.
}, shouldDelayActionsFetch ? CONST.FETCH_ACTIONS_DELAY.STARTUP : CONST.FETCH_ACTIONS_DELAY.RECONNECT));
});
}

Expand Down
73 changes: 73 additions & 0 deletions src/libs/actions/ReportActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import _ from 'underscore';
import Onyx from 'react-native-onyx';
import ONYXKEYS from '../../ONYXKEYS';
import * as CollectionUtils from '../CollectionUtils';

/**
* Map of the most recent non-loading sequenceNumber for a reportActions_* key in Onyx by reportID.
*
* What's the difference between reportMaxSequenceNumbers and reportActionsMaxSequenceNumbers?
*
* Knowing the maxSequenceNumber for a report does not necessarily mean we have stored the report actions for that
* report. To understand and optimize which reportActions we need to fetch we also keep track of the max sequenceNumber
* for the stored reportActions in reportActionsMaxSequenceNumbers. This allows us to initially download all
* reportActions when the app starts up and then only download the actions that we need when the app reconnects.
*
* This information should only be used in the correct contexts. In most cases, reportMaxSequenceNumbers should be
* referenced and not the locally stored reportAction's max sequenceNumber.
*/
const reportActionsMaxSequenceNumbers = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
callback: (actions, key) => {
if (!key || !actions) {
return;
}

const reportID = CollectionUtils.extractCollectionItemID(key);
const actionsArray = _.toArray(actions);
const mostRecentNonLoadingActionIndex = _.findLastIndex(actionsArray, action => !action.loading);
const mostRecentAction = actionsArray[mostRecentNonLoadingActionIndex];
if (!mostRecentAction || _.isUndefined(mostRecentAction.sequenceNumber)) {
return;
}

reportActionsMaxSequenceNumbers[reportID] = mostRecentAction.sequenceNumber;
},
});

/**
* WARNING: Do not use this method to access the maxSequenceNumber for a report. This ONLY returns the maxSequenceNumber
* for reportActions that are stored in Onyx under a reportActions_* key.
*
* @param {Number} reportID
* @param {Boolean} shouldWarn
* @returns {Number}
*/
function dangerouslyGetReportActionsMaxSequenceNumber(reportID, shouldWarn = true) {
if (shouldWarn) {
console.error('WARNING: dangerouslyGetReportActionsMaxSequenceNumber is unreliable as it ONLY references '
+ 'reportActions in storage. It should not be used to access the maxSequenceNumber for a report. Use '
+ 'reportMaxSequenceNumbers[reportID] instead.');
}

return reportActionsMaxSequenceNumbers[reportID];
}

/**
* Compares the maximum sequenceNumber that we know about with the most recent report action we have saved.
* If we have no report actions at all for the report we will assume that it is missing actions.
*
* @param {Number} reportID
* @param {Number} maxKnownSequenceNumber
* @returns {Boolean}
*/
function isReportMissingActions(reportID, maxKnownSequenceNumber) {
return _.isUndefined(reportActionsMaxSequenceNumbers[reportID])
|| reportActionsMaxSequenceNumbers[reportID] < maxKnownSequenceNumber;
}

export {
isReportMissingActions,
dangerouslyGetReportActionsMaxSequenceNumber,
};
9 changes: 2 additions & 7 deletions src/libs/actions/SignInRedirect.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import Onyx from 'react-native-onyx';
import ONYXKEYS from '../../ONYXKEYS';
import * as Pusher from '../Pusher/pusher';
import NetworkConnection from '../NetworkConnection';
import UnreadIndicatorUpdater from '../UnreadIndicatorUpdater';
import PushNotification from '../Notification/PushNotification';
import Timers from '../Timers';

let currentURL;
Onyx.connect({
Expand All @@ -26,20 +26,15 @@ Onyx.connect({
* @param {String} [errorMessage] error message to be displayed on the sign in page
*/
function redirectToSignIn(errorMessage) {
NetworkConnection.stopListeningForReconnect();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a duplicate call here and this should only need to trigger once when the AuthScreens unmount

https://github.com/Expensify/Expensify.cash/blob/2ae23e75b9c7b9166fb2a333bfcdd10646508448/src/libs/Navigation/AppNavigator/AuthScreens.js#L150

UnreadIndicatorUpdater.stopListeningForReportChanges();
PushNotification.deregister();
Pusher.disconnect();
Timers.clearAll();

if (!currentURL) {
return;
}

// If we are already on the signin page, don't redirect
if (currentURL.indexOf('signin') !== -1) {
return;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change here no longer made sense since there is no signin route


const activeClients = currentActiveClients;

// We must set the authToken to null so we can navigate to "signin" it's not possible to navigate to the route as
Expand Down