diff --git a/README.md b/README.md index 54426f64..aff26ccf 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,7 @@ type MiddlewareConfig = { regexActionType?: RegExp = /FETCH.*REQUEST/, actionTypes?: Array = [], queueReleaseThrottle?: number = 50, + shouldDequeueSelector: (state: RootReduxState) => boolean = () => true } ``` @@ -360,6 +361,9 @@ By default it's configured to intercept actions for fetching data following the `queueReleaseThrottle`: waiting time in ms between dispatches when flushing the offline queue. Useful to reduce the server pressure when coming back online. Defaults to 50ms. +`shouldDequeueSelector`: function that receives the redux application state and returns a boolean. It'll be executed every time an action is dispatched, before it reaches the reducer. This is useful to control if the queue should be released when the connection is regained and there were actions queued up. Returning `true` (the default behaviour) releases the queue, whereas returning `false` prevents queue release. For example, you may wanna perform some authentication checks, prior to releasing the queue. Note, if the result of `shouldDequeueSelector` changes *while* the queue is being released, the queue will not halt. If you want to halt the queue *while* is being released, please see relevant FAQ section. + + ##### Thunks Config For `redux-thunk` library, the async flow is wrapped inside functions that will be lazily evaluated when dispatched, so our store is able to dispatch functions as well. Therefore, the configuration differs: @@ -551,16 +555,14 @@ checkInternetConnection( ##### Example ```js -import { checkInternetConnection, offlineActionTypes } from 'react-native-offline'; +import { checkInternetConnection, offlineActionCreators } from 'react-native-offline'; async function internetChecker(dispatch) { const isConnected = await checkInternetConnection(); + const { connectionChange } = offlineActionCreators; // Dispatching can be done inside a connected component, a thunk (where dispatch is injected), saga, or any sort of middleware // In this example we are using a thunk - dispatch({ - type: offlineActionTypes.CONNECTION_CHANGE, - payload: isConnected, - }); + dispatch(connectionChange(isConnected)); } ``` @@ -584,21 +586,19 @@ As you can see in the snippets below, we create the `store` instance as usual an // configureStore.js import { createStore, applyMiddleware } from 'redux'; import { persistStore } from 'redux-persist'; -import { createNetworkMiddleware, offlineActionTypes, checkInternetConnection } from 'react-native-offline'; +import { createNetworkMiddleware, offlineActionCreators, checkInternetConnection } from 'react-native-offline'; import rootReducer from '../reducers'; const networkMiddleware = createNetworkMiddleware(); export default function configureStore(callback) { const store = createStore(rootReducer, applyMiddleware(networkMiddleware)); + const { connectionChange } = offlineActionCreators; // https://github.com/rt2zz/redux-persist#persiststorestore-config-callback persistStore(store, null, () => { // After rehydration completes, we detect initial connection checkInternetConnection().then(isConnected => { - store.dispatch({ - type: offlineActionTypes.CONNECTION_CHANGE, - payload: isConnected, - }); + store.dispatch(connectionChange(isConnected)); callback(); // Notify our root component we are good to go, so that we can render our app }); }); @@ -641,25 +641,32 @@ export default App; This way, we make sure the right actions are dispatched before anything else can be. -#### How to intercept and queue actions when the server responds with client (4xx) or server (5xx) errors -You can do that by dispatching yourself an action of type `@@network-connectivity/FETCH_OFFLINE_MODE`. The action types the library uses are exposed under `offlineActionTypes` property. +#### How do I stop the queue *while* it is being released? + +You can do that by dispatching a `CHANGE_QUEUE_SEMAPHORE` action using `changeQueueSemaphore` action creator. This action is used to manually stop and resume the queue even if it's being released. -Unfortunately, the action creators are not exposed yet, so I'll release soon a new version with that fixed. In the meantime, you can check that specific action creator in [here](https://github.com/rgommezz/react-native-offline/blob/master/src/actionCreators.js#L18), so that you can emulate its payload. That should queue up your action properly. +It works in the following way: if a `changeQueueSemaphore('RED')` action is dispatched, queue release is now halted. It will only resume if another if `changeQueueSemaphore('GREEN')` is dispatched. ```js -import { offlineActionTypes } from 'react-native-offline'; +import { offlineActionCreators } from 'react-native-offline'; +... +async function weHaltQeueeReleaseHere(){ + const { changeQueueSemaphore } = offlineActionCreators; + dispatch(changeQueueSemaphore('RED')) // The queue is now halted and it won't continue dispatching actions + await somePromise(); + dispatch(changeQueueSemaphore('GREEN')) // The queue is now resumed and it will continue dispatching actions +} +``` + + +#### How to intercept and queue actions when the server responds with client (4xx) or server (5xx) errors +You can do that by dispatching a `FETCH_OFFLINE_MODE` action using `fetchOfflineMode` action creator. + +```js +import { offlineActionCreators } from 'react-native-offline'; ... fetch('someurl/data').catch(error => { - dispatch({ - type: actionTypes.FETCH_OFFLINE_MODE, - payload: { - prevAction: { - type: action.type, // <-- action is the one that triggered your api call - payload: action.payload, - }, - }, - meta: { retry: true } - }) + dispatch(offlineActionCreators.fetchOfflineMode(action)) // <-- action is the one that triggered your api call ); ``` diff --git a/src/components/NetworkConnectivity.js b/src/components/NetworkConnectivity.js index b943be42..5e9aaaa7 100644 --- a/src/components/NetworkConnectivity.js +++ b/src/components/NetworkConnectivity.js @@ -56,7 +56,7 @@ function validateProps(props: Props) { throw new Error('httpMethod parameter should be either HEAD or OPTIONS'); } } - +/* eslint-disable react/default-props-match-prop-types */ class NetworkConnectivity extends React.PureComponent { static defaultProps = { onConnectivityChange: () => undefined, diff --git a/src/index.js b/src/index.js index cb900049..95eb820b 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,9 @@ module.exports = { get offlineActionTypes() { return require('./redux/actionTypes').default; }, + get offlineActionCreators() { + return require('./redux/actionCreators').default; + }, get networkSaga() { return require('./redux/sagas').default; }, diff --git a/src/redux/actionCreators.js b/src/redux/actionCreators.js index 79737466..5f78cb8e 100644 --- a/src/redux/actionCreators.js +++ b/src/redux/actionCreators.js @@ -6,6 +6,8 @@ import type { FluxActionWithPreviousIntent, FluxActionForRemoval, FluxActionForDismissal, + FluxActionForChangeQueueSemaphore, + SemaphoreColor, } from '../types'; type EnqueuedAction = FluxAction | Function; @@ -53,3 +55,18 @@ export const dismissActionsFromQueue = ( type: actionTypes.DISMISS_ACTIONS_FROM_QUEUE, payload: actionTrigger, }); + +export const changeQueueSemaphore = ( + semaphoreColor: SemaphoreColor, +): FluxActionForChangeQueueSemaphore => ({ + type: actionTypes.CHANGE_QUEUE_SEMAPHORE, + payload: semaphoreColor, +}); + +export default { + changeQueueSemaphore, + dismissActionsFromQueue, + removeActionFromQueue, + fetchOfflineMode, + connectionChange, +}; diff --git a/src/redux/actionTypes.js b/src/redux/actionTypes.js index 493e1930..24d12852 100644 --- a/src/redux/actionTypes.js +++ b/src/redux/actionTypes.js @@ -5,6 +5,7 @@ type ActionTypes = {| FETCH_OFFLINE_MODE: '@@network-connectivity/FETCH_OFFLINE_MODE', REMOVE_FROM_ACTION_QUEUE: '@@network-connectivity/REMOVE_FROM_ACTION_QUEUE', DISMISS_ACTIONS_FROM_QUEUE: '@@network-connectivity/DISMISS_ACTIONS_FROM_QUEUE', + CHANGE_QUEUE_SEMAPHORE: '@@network-connectivity/CHANGE_QUEUE_SEMAPHORE', |}; const actionTypes: ActionTypes = { @@ -13,6 +14,7 @@ const actionTypes: ActionTypes = { REMOVE_FROM_ACTION_QUEUE: '@@network-connectivity/REMOVE_FROM_ACTION_QUEUE', DISMISS_ACTIONS_FROM_QUEUE: '@@network-connectivity/DISMISS_ACTIONS_FROM_QUEUE', + CHANGE_QUEUE_SEMAPHORE: '@@network-connectivity/CHANGE_QUEUE_SEMAPHORE', }; export default actionTypes; diff --git a/src/redux/createNetworkMiddleware.js b/src/redux/createNetworkMiddleware.js index 5d19bd39..9d6b9402 100644 --- a/src/redux/createNetworkMiddleware.js +++ b/src/redux/createNetworkMiddleware.js @@ -9,6 +9,7 @@ import { import type { NetworkState } from '../types'; import networkActionTypes from './actionTypes'; import wait from '../utils/wait'; +import { SEMAPHORE_COLOR } from '../utils/constants'; type MiddlewareAPI = { dispatch: (action: any) => void, @@ -23,6 +24,7 @@ type Arguments = {| regexActionType: RegExp, actionTypes: Array, queueReleaseThrottle: number, + shouldDequeueSelector: (state: State) => boolean, |}; function validateParams(regexActionType, actionTypes) { @@ -70,11 +72,28 @@ function didComeBackOnline(action, wasConnected) { ); } -export const createReleaseQueue = (getState, next, delay) => async queue => { +function didQueueResume(action, isQueuePaused) { + return ( + action.type === networkActionTypes.CHANGE_QUEUE_SEMAPHORE && + isQueuePaused && + action.payload === SEMAPHORE_COLOR.GREEN + ); +} + +export const createReleaseQueue = ( + getState, + next, + delay, + shouldDequeueSelector, +) => async queue => { // eslint-disable-next-line for (const action of queue) { - const { isConnected } = getState().network; - if (isConnected) { + const state = getState(); + const { + network: { isConnected, isQueuePaused }, + } = state; + + if (isConnected && !isQueuePaused && shouldDequeueSelector(state)) { next(removeActionFromQueue(action)); next(action); // eslint-disable-next-line @@ -89,15 +108,17 @@ function createNetworkMiddleware({ regexActionType = /FETCH.*REQUEST/, actionTypes = [], queueReleaseThrottle = 50, + shouldDequeueSelector = () => true, }: Arguments = {}) { return ({ getState }: MiddlewareAPI) => ( next: (action: any) => void, ) => (action: any) => { - const { isConnected, actionQueue } = getState().network; + const { isConnected, actionQueue, isQueuePaused } = getState().network; const releaseQueue = createReleaseQueue( getState, next, queueReleaseThrottle, + shouldDequeueSelector, ); validateParams(regexActionType, actionTypes); @@ -114,7 +135,13 @@ function createNetworkMiddleware({ } const isBackOnline = didComeBackOnline(action, isConnected); - if (isBackOnline) { + const hasQueueBeenResumed = didQueueResume(action, isQueuePaused); + + const shouldDequeue = + (isBackOnline || (isConnected && hasQueueBeenResumed)) && + shouldDequeueSelector(getState()); + + if (shouldDequeue) { // Dispatching queued actions in order of arrival (if we have any) next(action); return releaseQueue(actionQueue); diff --git a/src/redux/createReducer.js b/src/redux/createReducer.js index 9f2eda36..b5cd7c2d 100644 --- a/src/redux/createReducer.js +++ b/src/redux/createReducer.js @@ -1,6 +1,7 @@ /* @flow */ import { get, without } from 'lodash'; +import { SEMAPHORE_COLOR } from '../utils/constants'; import actionTypes from './actionTypes'; import getSimilarActionInQueue from '../utils/getSimilarActionInQueue'; import type { @@ -8,11 +9,13 @@ import type { FluxActionWithPreviousIntent, FluxActionForRemoval, NetworkState, + SemaphoreColor, } from '../types'; export const initialState = { isConnected: true, actionQueue: [], + isQueuePaused: false, }; function handleOfflineAction( @@ -81,8 +84,20 @@ function handleDismissActionsFromQueue( }; } +function handleChangeQueueSemaphore( + state: NetworkState, + semaphoreColor: SemaphoreColor, +): NetworkState { + return { + ...state, + isQueuePaused: semaphoreColor === SEMAPHORE_COLOR.RED, + }; +} + export default (comparisonFn: Function = getSimilarActionInQueue) => ( - state: NetworkState = initialState, + state: NetworkState = { + ...initialState, + }, action: *, ): NetworkState => { switch (action.type) { @@ -97,6 +112,8 @@ export default (comparisonFn: Function = getSimilarActionInQueue) => ( return handleRemoveActionFromQueue(state, action.payload); case actionTypes.DISMISS_ACTIONS_FROM_QUEUE: return handleDismissActionsFromQueue(state, action.payload); + case actionTypes.CHANGE_QUEUE_SEMAPHORE: + return handleChangeQueueSemaphore(state, action.payload); default: return state; } diff --git a/src/types.js b/src/types.js index d365b56f..8c4f6bfa 100644 --- a/src/types.js +++ b/src/types.js @@ -4,6 +4,8 @@ export type State = { isConnected: boolean, }; +export type SemaphoreColor = 'RED' | 'GREEN'; + export type FluxAction = { type: string, payload: any, @@ -35,9 +37,15 @@ export type FluxActionForDismissal = { payload: string, }; +export type FluxActionForChangeQueueSemaphore = { + type: string, + payload: SemaphoreColor, +}; + export type NetworkState = { isConnected: boolean, actionQueue: Array<*>, + isQueuePaused: boolean, }; export type HTTPMethod = 'HEAD' | 'OPTIONS'; diff --git a/src/utils/constants.js b/src/utils/constants.js index c7363f73..2608b5be 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -2,3 +2,4 @@ export const DEFAULT_TIMEOUT = 10000; export const DEFAULT_PING_SERVER_URL = 'https://www.google.com/'; export const DEFAULT_HTTP_METHOD = 'HEAD'; +export const SEMAPHORE_COLOR = { RED: 'RED', GREEN: 'GREEN' }; diff --git a/test/createNetworkMiddleware.test.js b/test/createNetworkMiddleware.test.js index 948c34cb..71c7f3ae 100644 --- a/test/createNetworkMiddleware.test.js +++ b/test/createNetworkMiddleware.test.js @@ -5,8 +5,8 @@ import createNetworkMiddleware, { createReleaseQueue, } from '../src/redux/createNetworkMiddleware'; import * as actionCreators from '../src/redux/actionCreators'; -import { removeActionFromQueue } from '../src/redux/actionCreators'; import wait from '../src/utils/wait'; +import { SEMAPHORE_COLOR } from '../src/utils/constants'; const getFetchAction = type => ({ type, @@ -98,6 +98,26 @@ describe('createNetworkMiddleware with actionTypes in config', () => { const actions = store.getActions(); expect(actions).toEqual([actionCreators.connectionChange(true)]); }); + + it('action ENQUEUED, queue PAUSED, status queue RESUMED', async () => { + const action1 = getFetchAction('FETCH_SOME_DATA_REQUEST'); + const action2 = getFetchAction('FETCH_SOMETHING_ELSE_REQUEST'); + const action3 = getFetchAction('FETCH_USER_REQUEST'); + const prevActionQueue = [action1, action2, action3]; + const initialState = { + network: { + isConnected: true, + isQueuePaused: true, + actionQueue: prevActionQueue, + }, + }; + const store = mockStore(initialState); + store.dispatch(actionCreators.changeQueueSemaphore(SEMAPHORE_COLOR.GREEN)); + const actions = store.getActions(); + expect(actions).toEqual([ + actionCreators.changeQueueSemaphore(SEMAPHORE_COLOR.GREEN), + ]); + }); }); describe('createNetworkMiddleware with NO CONFIG', () => { @@ -373,39 +393,103 @@ describe('createNetworkMiddleware with dismissing actions functionality', () => }); }); +describe('createNetworkMiddleware with queueDeselector', () => { + const mockDequeueSelector = jest.fn(); + const networkMiddleware = createNetworkMiddleware({ + shouldDequeueSelector: mockDequeueSelector, + }); + const middlewares = [networkMiddleware]; + const mockStore = configureStore(middlewares); + + it('Proxies action to next middleware if deselector returns false', () => { + const initialState = { + network: { + isConnected: true, + actionQueue: [], + }, + }; + const store = mockStore(initialState); + const action = getFetchAction('REFRESH_DATA'); + store.dispatch(action); + + const actions = store.getActions(); + expect(actions).toEqual([getFetchAction('REFRESH_DATA')]); + }); +}); + describe('createReleaseQueue', () => { const mockDispatch = jest.fn(); - const mockGetState = jest.fn().mockImplementation(() => ({ - network: { - isConnected: true, - }, - })); + const mockGetState = jest.fn(); + const mockDequeueSelector = jest.fn(); const mockDelay = 50; + + beforeEach(() => { + mockDequeueSelector.mockImplementation(() => true); + mockGetState.mockImplementation(() => ({ + network: { + isConnected: true, + isQueuePaused: false, + }, + })); + }); + afterEach(() => { mockDispatch.mockClear(); mockGetState.mockClear(); }); - it('empties the queue if we are online', async () => { + + it('empties the queue if we are online and queue is not halted', async () => { const releaseQueue = createReleaseQueue( mockGetState, mockDispatch, mockDelay, + mockDequeueSelector, ); const actionQueue = ['foo', 'bar']; await releaseQueue(actionQueue); expect(mockDispatch).toHaveBeenCalledTimes(4); expect(mockDispatch).toHaveBeenNthCalledWith( 1, - removeActionFromQueue('foo'), + actionCreators.removeActionFromQueue('foo'), ); expect(mockDispatch).toHaveBeenNthCalledWith(2, 'foo'); expect(mockDispatch).toHaveBeenNthCalledWith( 3, - removeActionFromQueue('bar'), + actionCreators.removeActionFromQueue('bar'), ); expect(mockDispatch).toHaveBeenNthCalledWith(4, 'bar'); }); + it('does not empty the queue if dequeue selector returns false', async () => { + const mockDequeueSelector = () => false; + const releaseQueue = createReleaseQueue( + mockGetState, + mockDispatch, + mockDelay, + mockDequeueSelector, + ); + const actionQueue = ['foo', 'bar']; + await releaseQueue(actionQueue); + expect(mockDispatch).toHaveBeenCalledTimes(0); + }); + + it('does not empty the queue if queue has been halted', async () => { + mockGetState.mockImplementation(() => ({ + network: { + isQueuePaused: true, + }, + })); + const releaseQueue = createReleaseQueue( + mockGetState, + mockDispatch, + mockDelay, + mockDequeueSelector, + ); + const actionQueue = ['foo', 'bar']; + await releaseQueue(actionQueue); + expect(mockDispatch).toHaveBeenCalledTimes(0); + }); + it('dispatches only during the online window', async () => { const switchToOffline = () => new Promise(async resolve => { @@ -421,13 +505,14 @@ describe('createReleaseQueue', () => { mockGetState, mockDispatch, mockDelay, + mockDequeueSelector, ); const actionQueue = ['foo', 'bar']; await Promise.all([releaseQueue(actionQueue), switchToOffline()]); expect(mockDispatch).toHaveBeenCalledTimes(2); expect(mockDispatch).toHaveBeenNthCalledWith( 1, - removeActionFromQueue('foo'), + actionCreators.removeActionFromQueue('foo'), ); expect(mockDispatch).toHaveBeenNthCalledWith(2, 'foo'); }); diff --git a/test/reducer.test.js b/test/reducer.test.js index bedb66cc..342f82e0 100644 --- a/test/reducer.test.js +++ b/test/reducer.test.js @@ -12,6 +12,7 @@ const networkReducer = createReducer(); const getState = (isConnected = false, ...actionQueue) => ({ isConnected, actionQueue, + isQueuePaused: false, }); /** Actions used from now on to test different scenarios */ @@ -63,6 +64,7 @@ describe('CONNECTION_CHANGE action type', () => { expect(networkReducer(initialState, mockAction)).toEqual({ isConnected: false, actionQueue: [], + isQueuePaused: false, }); }); }); @@ -106,6 +108,7 @@ describe('OFFLINE_ACTION action type', () => { expect(nextState).toEqual({ isConnected: false, actionQueue: [prevActionToRetry1], + isQueuePaused: false, }); const action2 = actionCreators.fetchOfflineMode(prevActionToRetry2); @@ -236,6 +239,26 @@ describe('REMOVE_ACTION_FROM_QUEUE action type', () => { }); }); +describe('QUEUE_SEMAPHORE_CHANGE action type', () => { + it('Pauses the queue if semaphore is red', () => { + expect( + networkReducer(undefined, actionCreators.changeQueueSemaphore('RED')), + ).toEqual({ + ...initialState, + isQueuePaused: true, + }); + }); + + it('Resumes the queue if semaphore is green', () => { + expect( + networkReducer(undefined, actionCreators.changeQueueSemaphore('GREEN')), + ).toEqual({ + ...initialState, + isQueuePaused: false, + }); + }); +}); + describe('thunks', () => { function fetchThunk(dispatch) { dispatch({ type: 'FETCH_DATA_REQUEST' });