Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 5 additions & 46 deletions src/lib/API.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import _ from 'underscore';
import Ion from './Ion';
import IONKEYS from '../IONKEYS';
import {xhr, setOfflineStatus} from './Network';
import xhr from './xhr';
import NetworkConnection from './NetworkConnection';
import CONFIG from '../CONFIG';
import * as Pusher from './Pusher/pusher';
import ROUTES from '../ROUTES';
import Str from './Str';
import Guid from './Guid';
import redirectToSignIn from './actions/SignInRedirect';
import Activity from './Activity';

// Holds all of the callbacks that need to be triggered when the network reconnects
const reconnectionCallbacks = [];

// Queue for network requests so we don't lose actions done by the user while offline
let networkRequestQueue = [];
Expand All @@ -27,39 +24,12 @@ Ion.connect({
callback: val => authToken = val ? val.authToken : null,
});

/**
* Loop over all reconnection callbacks and fire each one
*/
function triggerReconnectionCallbacks() {
_.each(reconnectionCallbacks, callback => callback());
}

// We subscribe to changes to the online/offline status of the network to determine when we should fire off API calls
// vs queueing them for later. When reconnecting, ie, going from offline to online, all the reconnection callbacks
// are triggered (this is usually Actions that need to re-download data from the server)
// vs queueing them for later.
let isOffline;
Ion.connect({
key: IONKEYS.NETWORK,
callback: (val) => {
if (isOffline && !val.isOffline) {
triggerReconnectionCallbacks();
}
isOffline = val && val.isOffline;
}
});

// When the app is in the background Pusher can still receive realtime updates
// for a few minutes, but eventually disconnects causing a delay when the app
// returns from the background. So, if we are returning from the background
// and we are online we should trigger our reconnection callbacks.
Activity.registerOnAppBecameActiveCallback(() => {
// If we know we are offline and we have no authToken then there's
// no reason to trigger the reconnection callbacks as they will fail.
if (isOffline || !authToken) {
return;
}

triggerReconnectionCallbacks();
callback: val => isOffline = val && val.isOffline,
});

// When the user authenticates for the first time we create a login and store credentials in Ion.
Expand Down Expand Up @@ -264,7 +234,7 @@ function processNetworkRequestQueue() {

// Make a simple request every second to see if the API is online again
xhr('Get', {doNotRetry: true})
.then(() => setOfflineStatus(false));
.then(() => NetworkConnection.setOfflineStatus(false));
return;
}

Expand Down Expand Up @@ -340,16 +310,6 @@ Pusher.registerSocketEventCallback((eventName) => {
}
});

/**
* Register a callback function to be called when the network reconnects
*
* @public
* @param {function} cb
*/
function onReconnect(cb) {
reconnectionCallbacks.push(cb);
}

/**
* Get the authToken that the network uses
* @returns {string}
Expand Down Expand Up @@ -493,6 +453,5 @@ export {
getAuthToken,
getPersonalDetails,
getReportHistory,
onReconnect,
setLastReadActionID,
};
7 changes: 0 additions & 7 deletions src/lib/Activity/index.js

This file was deleted.

22 changes: 0 additions & 22 deletions src/lib/Activity/index.native.js

This file was deleted.

111 changes: 111 additions & 0 deletions src/lib/NetworkConnection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import _ from 'underscore';
import {AppState} from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import Ion from './Ion';
import IONKEYS from '../IONKEYS';

// NetInfo.addEventListener() returns a function used to unsubscribe the
// listener so we must create a reference to it and call it in stopListeningForReconnect()
let unsubscribeFromNetInfo;
let sleepTimer;
let lastTime;
let isActive = false;
let isOffline = false;

// Holds all of the callbacks that need to be triggered when the network reconnects
const reconnectionCallbacks = [];

/**
* Loop over all reconnection callbacks and fire each one
*/
const triggerReconnectionCallbacks = _.throttle(() => {
_.each(reconnectionCallbacks, callback => callback());
}, 5000, {trailing: false});

/**
* Called when the offline status of the app changes and if the network is "reconnecting" (going from offline to online)
* then all of the reconnection callbacks are triggered
*
* @param {boolean} isCurrentlyOffline
*/
function setOfflineStatus(isCurrentlyOffline) {
Ion.merge(IONKEYS.NETWORK, {isOffline: isCurrentlyOffline});

// When reconnecting, ie, going from offline to online, all the reconnection callbacks
// are triggered (this is usually Actions that need to re-download data from the server)
if (isOffline && !isCurrentlyOffline) {
triggerReconnectionCallbacks();
}

isOffline = isCurrentlyOffline;
}

/**
* Set up the event listener for NetInfo to tell whether the user has
* internet connectivity or not. This is more reliable than the Pusher
* `disconnected` event which takes about 10-15 seconds to emit.
*/
function listenForReconnect() {
// Subscribe to the state change event via NetInfo so we can update
// whether a user has internet connectivity or not.
unsubscribeFromNetInfo = NetInfo.addEventListener((state) => {
console.debug('[NetInfo] isConnected:', state && state.isConnected);
setOfflineStatus(!state.isConnected);
});

// When the app is in the background Pusher can still receive realtime updates
// for a few minutes, but eventually disconnects causing a delay when the app
// returns from the background. So, if we are returning from the background
// and we are online we should trigger our reconnection callbacks.
AppState.addEventListener('change', (state) => {
console.debug('[AppState] state changed:', state);
const nextStateIsActive = state === 'active';

// We are moving from not active to active and we are online so fire callbacks
if (!isOffline && nextStateIsActive && !isActive) {
triggerReconnectionCallbacks();
}

isActive = nextStateIsActive;
});

// When a device is put to sleep, NetInfo is not always able to detect
// when connectivity has been lost. As a failsafe we will capture the time
// every two seconds and if the last time recorded is greater than 5 seconds
// we know that the computer has been asleep.
lastTime = (new Date()).getTime();
sleepTimer = setInterval(() => {
Copy link
Contributor

@cead22 cead22 Oct 6, 2020

Choose a reason for hiding this comment

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

Is it worth using setTimeout instead of setInterval (and maybe establishing this as a best practice for JS)?

This is an old "best practice" but I think it's still valid, and the idea behind it is that if there's an uncaught error that prevents clearInterval from running, this function will be running in a "loop" forever.

To avoid that, I think we can use setTimeout instead like so:

We define it as a recursive function that calls setTimeout

function listenForReconnect() {
...

    // define it
    var checkConnectivity = () => {
        setTimeout(() => {
            const currentTime = (new Date()).getTime();
            if (currentTime > (lastTime + 5000)) {
                triggerReconnectionCallbacks();
            }
            lastTime = currentTime;
            checkConnectivity();
        }, 2000)
    };

    // call it once
    checkConnectivity()
    ...
}

And when we stop listening, we re-define it as a no-op

function stopListeningForReconnect() {
    // make it a no-op
    checkConnectivity = () => {};
    ...
}

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 really interesting point. And I guess my only question would be what exactly would cause the clearInterval() to never run in this case? We are calling it via redirectToSignIn(), but I'm not seeing how (or which) exception would prevent it from running. And if there was such an exception then wouldn't that same exception prevent you from setting checkConnectivity as a no-op?

Copy link
Contributor

Choose a reason for hiding this comment

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

what exactly would cause the clearInterval() to never run in this case?

I don't know, it's mostly a defensive measure in case we ever write a bug that causes an error that isn't caught

And if there was such an exception then wouldn't that same exception prevent you from setting checkConnectivity as a no-op?

Duh, yeah 🤦. I was looking for the right way to do this since I haven't thought about it in a while, so clearly this isn't it, but I bet there is a way. I'll look into it, and will merge this now

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh actually, I think what happens is that if there's an uncaught exception and we don't make checkConnectivity a no-op, it doesn't really matter because we didn't call setInterval, and thus, the execution of checkConnectivity will be interrupted too. Does that make sense, and do you agree with that being how it'd work?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, I think I get it now! You are saying that if something inside the checkConnectivity method fails then we will stop looping. Therefore it's better to use recursion here since we don't want to keep calling a method that we already know is going to fail.

I think that makes sense, but another perspective might be that setInterval is only bad if we allow these unhandled errors to exist unhandled. Consider this case:

myInterval = setInterval(() => {
	try {
        breakStuff();
    } catch(err) {
        clearInterval(myInterval);
        throw err;
    }
}, 1000);

I like this better because we don't need to no-op anything or use recursion. If we encounter an error then we just decide to stop the loop and throw the error.

Copy link
Contributor

Choose a reason for hiding this comment

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

I like that idea of using a try/catch. I think that's cleaner than setTimeout. I'd also like to see that added as a utility like safeSetInterval or something (though not necessary for this).

Copy link
Contributor

Choose a reason for hiding this comment

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

You are saying that if something inside the checkConnectivity method fails then we will stop looping.

No, not really. I think any uncaught error can stop the execution of JS code, is that not the case with React?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

React is JavaScript and follows the same rules. The suggestion that an uncaught error from anywhere could somehow stop a recursive setTimeout() I think is wrong? Timers have their own execution context and can only be "broken" if an error is thrown inside the timer.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yup, not from anywhere, but if triggerReconnectionCallbacks(); or anything that that function calls throws, then the interval won't be cleared. So the try/catch you propose should work well

const currentTime = (new Date()).getTime();
if (currentTime > (lastTime + 5000)) {
triggerReconnectionCallbacks();
}
lastTime = currentTime;
}, 2000);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Sounds like a good thing to try!

}

/**
* Tear down the event listeners when we are finished with them.
*/
function stopListeningForReconnect() {
clearInterval(sleepTimer);
if (unsubscribeFromNetInfo) {
unsubscribeFromNetInfo();
}
AppState.removeEventListener('change', () => {});
}

/**
* Register callback to fire when we reconnect
*
* @param {Function} callback
*/
function onReconnect(callback) {
reconnectionCallbacks.push(callback);
}

export default {
setOfflineStatus,
listenForReconnect,
stopListeningForReconnect,
onReconnect,
};
3 changes: 2 additions & 1 deletion src/lib/actions/PersonalDetails.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as API from '../API';
import IONKEYS from '../../IONKEYS';
import md5 from '../md5';
import CONST from '../../CONST';
import NetworkConnection from '../NetworkConnection';

let currentUserEmail;
Ion.connect({
Expand Down Expand Up @@ -138,7 +139,7 @@ function getForEmails(emailList) {
}

// When the app reconnects from being offline, fetch all of the personal details
API.onReconnect(fetch);
NetworkConnection.onReconnect(fetch);

export {
fetch,
Expand Down
3 changes: 2 additions & 1 deletion src/lib/actions/Report.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as PersonalDetails from './PersonalDetails';
import {redirect} from './App';
import * as ActiveClientManager from '../ActiveClientManager';
import Visibility from '../Visibility';
import NetworkConnection from '../NetworkConnection';

let currentUserEmail;
let currentUserAccountID;
Expand Down Expand Up @@ -499,7 +500,7 @@ Ion.connect({
});

// When the app reconnects from being offline, fetch all of the reports and their actions
API.onReconnect(() => {
NetworkConnection.onReconnect(() => {
fetchAll(false, true);
});

Expand Down
2 changes: 2 additions & 0 deletions src/lib/actions/SignInRedirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import IONKEYS from '../../IONKEYS';
import ROUTES from '../../ROUTES';
import {redirect} from './App';
import * as Pusher from '../Pusher/pusher';
import NetworkConnection from '../NetworkConnection';

let currentURL;
Ion.connect({
Expand All @@ -17,6 +18,7 @@ Ion.connect({
* @param {String} [errorMessage] error message to be displayed on the sign in page
*/
function redirectToSignIn(errorMessage) {
NetworkConnection.stopListeningForReconnect();
Pusher.disconnect();
Ion.clear()
.then(() => {
Expand Down
26 changes: 3 additions & 23 deletions src/lib/Network.js → src/lib/xhr.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,8 @@
import _ from 'underscore';
import NetInfo from '@react-native-community/netinfo';
import Ion from './Ion';
import CONFIG from '../CONFIG';
import IONKEYS from '../IONKEYS';

/**
* Called when the offline status of the app changes and if the network is "reconnecting" (going from offline to online)
* then all of the reconnection callbacks are triggered
*
* @param {boolean} isCurrentlyOffline
*/
function setOfflineStatus(isCurrentlyOffline) {
Ion.merge(IONKEYS.NETWORK, {isOffline: isCurrentlyOffline});
}

// Subscribe to the state change event via NetInfo so we can update
// whether a user has internet connectivity or not. This is more reliable
// than the Pusher `disconnected` event which takes about 10-15 seconds to emit
NetInfo.addEventListener((state) => {
setOfflineStatus(!state.isConnected);
});
import NetworkConnection from './NetworkConnection';

/**
* Makes XHR request
Expand All @@ -41,7 +24,7 @@ function xhr(command, data, type = 'post') {
// This will catch any HTTP network errors (like 404s and such), not to be confused with jsonCode which this
// does NOT catch
.catch(() => {
setOfflineStatus(true);
NetworkConnection.setOfflineStatus(true);

// Set an error state and signify we are done loading
Ion.merge(IONKEYS.SESSION, {loading: false, error: 'Cannot connect to server'});
Expand All @@ -52,7 +35,4 @@ function xhr(command, data, type = 'post') {
});
}

export {
xhr,
setOfflineStatus,
};
export default xhr;
2 changes: 2 additions & 0 deletions src/page/home/HomePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Main from './MainView';
import {subscribeToReportCommentEvents, fetchAll as fetchAllReports} from '../../lib/actions/Report';
import {fetch as fetchPersonalDetails} from '../../lib/actions/PersonalDetails';
import * as Pusher from '../../lib/Pusher/pusher';
import NetworkConnection from '../../lib/NetworkConnection';

const windowSize = Dimensions.get('window');
const widthBreakPoint = 1000;
Expand All @@ -36,6 +37,7 @@ export default class App extends React.Component {
}

componentDidMount() {
NetworkConnection.listenForReconnect();
Pusher.init().then(subscribeToReportCommentEvents);

// Fetch all the personal details
Expand Down