diff --git a/lib/utils/action-utils.js b/lib/utils/action-utils.js index 14e0ac8ca..4e9f335a6 100644 --- a/lib/utils/action-utils.js +++ b/lib/utils/action-utils.js @@ -1,498 +1,427 @@ // @flow -import invariant from 'invariant'; -import _mapValues from 'lodash/fp/mapValues'; import _memoize from 'lodash/memoize'; import * as React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { cookieInvalidationResolutionAttempt } from '../actions/user-actions'; import { serverCallStateSelector } from '../selectors/server-calls'; import type { LogInActionSource, LogInStartingPayload, LogInResult, } from '../types/account-types'; import type { Endpoint, SocketAPIHandler } from '../types/endpoints'; import type { LoadingOptions, LoadingInfo } from '../types/loading-types'; import type { ActionPayload, Dispatch, PromisedAction, BaseAction, } from '../types/redux-types'; import type { ClientSessionChange, PreRequestUserState, } from '../types/session-types'; import type { ConnectionStatus } from '../types/socket-types'; import type { CurrentUserInfo } from '../types/user-types'; import { getConfig } from './config'; import fetchJSON from './fetch-json'; import type { FetchJSON, FetchJSONOptions } from './fetch-json'; let nextPromiseIndex = 0; export type ActionTypes = { started: AT, success: BT, failed: CT, }; function wrapActionPromise< AT: string, // *_STARTED action type (string literal) AP: ActionPayload, // *_STARTED payload BT: string, // *_SUCCESS action type (string literal) BP: ActionPayload, // *_SUCCESS payload CT: string, // *_FAILED action type (string literal) >( actionTypes: ActionTypes, promise: Promise, loadingOptions: ?LoadingOptions, startingPayload: ?AP, ): PromisedAction { const loadingInfo: LoadingInfo = { fetchIndex: nextPromiseIndex++, trackMultipleRequests: !!( loadingOptions && loadingOptions.trackMultipleRequests ), customKeyName: loadingOptions && loadingOptions.customKeyName ? loadingOptions.customKeyName : null, }; return async (dispatch: Dispatch): Promise => { const startAction = startingPayload ? { type: (actionTypes.started: AT), loadingInfo, payload: (startingPayload: AP), } : { type: (actionTypes.started: AT), loadingInfo, }; dispatch(startAction); try { const result = await promise; dispatch({ type: (actionTypes.success: BT), payload: (result: BP), loadingInfo, }); } catch (e) { console.log(e); dispatch({ type: (actionTypes.failed: CT), error: true, payload: (e: Error), loadingInfo, }); } }; } -export type DispatchActionPayload = ( - actionType: T, - payload: P, -) => void; export type DispatchActionPromise = < A: BaseAction, B: BaseAction, C: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ) => Promise; function useDispatchActionPromise() { const dispatch = useDispatch(); return React.useMemo(() => createDispatchActionPromise(dispatch), [dispatch]); } function createDispatchActionPromise(dispatch: Dispatch) { const dispatchActionPromise = function < A: BaseAction, B: BaseAction, C: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ): Promise { return dispatch( wrapActionPromise(actionTypes, promise, loadingOptions, startingPayload), ); }; return dispatchActionPromise; } export type DispatchFunctions = {| +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, |}; -type LegacyDispatchFunctions = { - dispatch: Dispatch, - dispatchActionPayload: DispatchActionPayload, - dispatchActionPromise: DispatchActionPromise, -}; -function includeDispatchActionProps( - dispatch: Dispatch, -): LegacyDispatchFunctions { - const dispatchActionPromise = createDispatchActionPromise(dispatch); - const dispatchActionPayload = function ( - actionType: T, - payload: P, - ) { - const action = { type: actionType, payload }; - dispatch(action); - }; - return { dispatch, dispatchActionPayload, dispatchActionPromise }; -} - let currentlyWaitingForNewCookie = false; let fetchJSONCallsWaitingForNewCookie: ((fetchJSON: ?FetchJSON) => void)[] = []; export type DispatchRecoveryAttempt = ( actionTypes: ActionTypes<'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED'>, promise: Promise, startingPayload: LogInStartingPayload, ) => Promise; const setNewSessionActionType = 'SET_NEW_SESSION'; function setNewSession( dispatch: Dispatch, sessionChange: ClientSessionChange, preRequestUserState: ?PreRequestUserState, error: ?string, source: ?LogInActionSource, ) { dispatch({ type: setNewSessionActionType, payload: { sessionChange, preRequestUserState, error, source }, }); } // This function calls resolveInvalidatedCookie, which dispatchs a log in action // using the native credentials. Note that we never actually specify a sessionID // here, on the assumption that only native clients will call this. (Native // clients don't specify a sessionID, indicating to the server that it should // use the cookieID as the sessionID.) async function fetchNewCookieFromNativeCredentials( dispatch: Dispatch, cookie: ?string, urlPrefix: string, source: LogInActionSource, ): Promise { const resolveInvalidatedCookie = getConfig().resolveInvalidatedCookie; if (!resolveInvalidatedCookie) { return null; } let newSessionChange = null; let fetchJSONCallback = null; const boundFetchJSON = async ( endpoint: Endpoint, data: { [key: string]: mixed }, options?: ?FetchJSONOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; setNewSession(dispatch, sessionChange, null, error, source); }; try { const result = await fetchJSON( cookie, innerBoundSetNewSession, () => new Promise((r) => r(null)), () => new Promise((r) => r(null)), urlPrefix, null, 'disconnected', null, endpoint, data, options, ); if (fetchJSONCallback) { fetchJSONCallback(!!newSessionChange); } return result; } catch (e) { if (fetchJSONCallback) { fetchJSONCallback(!!newSessionChange); } throw e; } }; const dispatchRecoveryAttempt = ( actionTypes: ActionTypes< 'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED', >, promise: Promise, inputStartingPayload: LogInStartingPayload, ) => { const startingPayload = { ...inputStartingPayload, source }; dispatch(wrapActionPromise(actionTypes, promise, null, startingPayload)); return new Promise((r) => (fetchJSONCallback = r)); }; await resolveInvalidatedCookie( boundFetchJSON, dispatchRecoveryAttempt, source, ); return newSessionChange; } // Third param is optional and gets called with newCookie if we get a new cookie // Necessary to propagate cookie in cookieInvalidationRecovery below function bindCookieAndUtilsIntoFetchJSON( params: BindServerCallsParams, ): FetchJSON { const { dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = params; const loggedIn = !!(currentUserInfo && !currentUserInfo.anonymous && true); const boundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => setNewSession( dispatch, sessionChange, { currentUserInfo, cookie, sessionID }, error, undefined, ); // This function gets called before fetchJSON sends a request, to make sure // that we're not in the middle of trying to recover an invalidated cookie const waitIfCookieInvalidated = () => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // fetchJSON instance continue return Promise.resolve(null); } if (!currentlyWaitingForNewCookie) { // Our cookie seems to be valid return Promise.resolve(null); } // Wait to run until we get our new cookie return new Promise((r) => fetchJSONCallsWaitingForNewCookie.push(r)); }; // This function is a helper for the next function defined below const attemptToResolveInvalidation = async ( sessionChange: ClientSessionChange, ) => { const newAnonymousCookie = sessionChange.cookie; const newSessionChange = await fetchNewCookieFromNativeCredentials( dispatch, newAnonymousCookie, urlPrefix, cookieInvalidationResolutionAttempt, ); currentlyWaitingForNewCookie = false; const currentWaitingCalls = fetchJSONCallsWaitingForNewCookie; fetchJSONCallsWaitingForNewCookie = []; const newFetchJSON = newSessionChange ? bindCookieAndUtilsIntoFetchJSON({ ...params, cookie: newSessionChange.cookie, sessionID: newSessionChange.sessionID, currentUserInfo: newSessionChange.currentUserInfo, }) : null; for (const func of currentWaitingCalls) { func(newFetchJSON); } return newFetchJSON; }; // If this function is called, fetchJSON got a response invalidating its // cookie, and is wondering if it should just like... give up? Or if there's // a chance at redemption const cookieInvalidationRecovery = (sessionChange: ClientSessionChange) => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // fetchJSON instance continue return Promise.resolve(null); } if (!loggedIn) { // We don't want to attempt any use native credentials of a logged out // user to log-in after a cookieInvalidation while logged out return Promise.resolve(null); } if (currentlyWaitingForNewCookie) { return new Promise((r) => fetchJSONCallsWaitingForNewCookie.push(r)); } currentlyWaitingForNewCookie = true; return attemptToResolveInvalidation(sessionChange); }; return (endpoint: Endpoint, data: Object, options?: ?FetchJSONOptions) => fetchJSON( cookie, boundSetNewSession, waitIfCookieInvalidated, cookieInvalidationRecovery, urlPrefix, sessionID, connectionStatus, socketAPIHandler, endpoint, data, options, ); } export type ActionFunc = ( fetchJSON: FetchJSON, ...rest: $FlowFixMe ) => Promise<*>; type BindServerCallsParams = {| dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, |}; // All server calls needs to include some information from the Redux state // (namely, the cookie). This information is used deep in the server call, // at the point where fetchJSON is called. We don't want to bother propagating // the cookie (and any future config info that fetchJSON needs) through to the // server calls so they can pass it to fetchJSON. Instead, we "curry" the cookie // onto fetchJSON within react-redux's connect's mapStateToProps function, and // then pass that "bound" fetchJSON that no longer needs the cookie as a // parameter on to the server call. const baseCreateBoundServerCallsSelector = (actionFunc: ActionFunc) => { return createSelector( (state: BindServerCallsParams) => state.dispatch, (state: BindServerCallsParams) => state.cookie, (state: BindServerCallsParams) => state.urlPrefix, (state: BindServerCallsParams) => state.sessionID, (state: BindServerCallsParams) => state.currentUserInfo, (state: BindServerCallsParams) => state.connectionStatus, ( dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, ) => { const boundFetchJSON = bindCookieAndUtilsIntoFetchJSON({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }); return (...rest: $FlowFixMe) => actionFunc(boundFetchJSON, ...rest); }, ); }; const createBoundServerCallsSelector: ( actionFunc: ActionFunc, ) => (state: BindServerCallsParams) => BoundServerCall = _memoize( baseCreateBoundServerCallsSelector, ); -export type ServerCalls = { [name: string]: ActionFunc }; export type BoundServerCall = (...rest: $FlowFixMe) => Promise; -function bindServerCalls(serverCalls: ServerCalls) { - return ( - stateProps: { - cookie: ?string, - urlPrefix: string, - sessionID: ?string, - currentUserInfo: ?CurrentUserInfo, - connectionStatus: ConnectionStatus, - }, - dispatchProps: Object, - ownProps: { [propName: string]: mixed }, - ) => { - const dispatch = dispatchProps.dispatch; - invariant(dispatch, 'should be defined'); - const { - cookie, - urlPrefix, - sessionID, - currentUserInfo, - connectionStatus, - } = stateProps; - const boundServerCalls = _mapValues( - (serverCall: (fetchJSON: FetchJSON, ...rest: any) => Promise) => - createBoundServerCallsSelector(serverCall)({ - dispatch, - cookie, - urlPrefix, - sessionID, - currentUserInfo, - connectionStatus, - }), - )(serverCalls); - return { - ...ownProps, - ...stateProps, - ...dispatchProps, - ...boundServerCalls, - }; - }; -} - function useServerCall(serverCall: ActionFunc): BoundServerCall { const dispatch = useDispatch(); - const serverCallState = useSelector((state) => - serverCallStateSelector(state), - ); + const serverCallState = useSelector(serverCallStateSelector); return React.useMemo( () => createBoundServerCallsSelector(serverCall)({ ...serverCallState, dispatch, }), [serverCall, dispatch, serverCallState], ); } let socketAPIHandler: ?SocketAPIHandler = null; function registerActiveSocket(passedSocketAPIHandler: ?SocketAPIHandler) { socketAPIHandler = passedSocketAPIHandler; } export { useDispatchActionPromise, setNewSessionActionType, - includeDispatchActionProps, fetchNewCookieFromNativeCredentials, createBoundServerCallsSelector, - bindServerCalls, registerActiveSocket, useServerCall, }; diff --git a/lib/utils/redux-utils.js b/lib/utils/redux-utils.js index 3aeaa7e10..623fedd20 100644 --- a/lib/utils/redux-utils.js +++ b/lib/utils/redux-utils.js @@ -1,87 +1,14 @@ // @flow -import invariant from 'invariant'; -import { - connect as reactReduxConnect, - useSelector as reactReduxUseSelector, -} from 'react-redux'; +import { useSelector as reactReduxUseSelector } from 'react-redux'; -import { serverCallStateSelector } from '../selectors/server-calls'; import type { AppState } from '../types/redux-types'; -import type { ConnectionStatus } from '../types/socket-types'; -import type { CurrentUserInfo } from '../types/user-types'; -import type { ServerCalls } from './action-utils'; -import { includeDispatchActionProps, bindServerCalls } from './action-utils'; - -function connect( - inputMapStateToProps: ?(state: S, ownProps: OP) => SP, - serverCalls?: ?ServerCalls, - includeDispatch?: boolean, -): * { - const mapStateToProps = inputMapStateToProps; - const serverCallExists = serverCalls && Object.keys(serverCalls).length > 0; - let mapState = null; - if (serverCallExists && mapStateToProps && mapStateToProps.length > 1) { - mapState = ( - state: S, - ownProps: OP, - ): { - ...SP, - cookie: ?string, - urlPrefix: string, - sessionID: ?string, - currentUserInfo: ?CurrentUserInfo, - connectionStatus: ConnectionStatus, - } => ({ - ...mapStateToProps(state, ownProps), - ...serverCallStateSelector(state), - }); - } else if (serverCallExists && mapStateToProps) { - mapState = ( - state: S, - ): { - ...SP, - cookie: ?string, - urlPrefix: string, - sessionID: ?string, - currentUserInfo: ?CurrentUserInfo, - connectionStatus: ConnectionStatus, - } => ({ - // $FlowFixMe - ...mapStateToProps(state), - ...serverCallStateSelector(state), - }); - } else if (mapStateToProps) { - mapState = mapStateToProps; - } else if (serverCallExists) { - mapState = serverCallStateSelector; - } - const dispatchIncluded = - includeDispatch === true || - (includeDispatch === undefined && serverCallExists); - if (dispatchIncluded && serverCallExists) { - invariant(mapState && serverCalls, 'should be set'); - return reactReduxConnect( - mapState, - includeDispatchActionProps, - bindServerCalls(serverCalls), - ); - } else if (dispatchIncluded) { - return reactReduxConnect(mapState, includeDispatchActionProps); - } else if (serverCallExists) { - invariant(mapState && serverCalls, 'should be set'); - return reactReduxConnect(mapState, undefined, bindServerCalls(serverCalls)); - } else { - invariant(mapState, 'should be set'); - return reactReduxConnect(mapState); - } -} function useSelector( selector: (state: AppState) => SS, equalityFn?: (a: SS, b: SS) => boolean, ): SS { return reactReduxUseSelector(selector, equalityFn); } -export { connect, useSelector }; +export { useSelector };