From 725992941c9e986dca1b9a22bda7fa8cdcd7aad6 Mon Sep 17 00:00:00 2001 From: Amit Uttam Date: Wed, 1 Apr 2020 00:01:13 -0300 Subject: [PATCH] [MM-23490] Save state to file via async middleware vs store subscription (#4059) * MM-23490 Save state to file via async middleware vs store subscription Currently for iOS, a subset of store state is saved to an on-device file, so that the Share Extension can have access to information it needs (teams and channels) to function. This file saving would happen via a store subscription which triggers a file save for every dispatched action. By moving this logic to a middleware function, when this function gets invoked is now limited to a configurable set of action dispatches. (e.g. `LOGIN`, `CONNECTION_CHANGED`, `WEBSOCKET_SUCCESS`), etc. * MM-23493 Move app cache purge from store subscription to middleware (#4069) * MM-23493 Move app cache purge from store subscription to middleware This commit exposes persistence configuration as a static reference, so that cache purging can be invoked on demand anywhere else in the codebase. While middleware still may not be the best spot for this singular "action", existing functionality (reacting to `OFFLINE_STORE_PURGE`) is maintained. The change also removes the need for `state.views.root.purge` to exist in the state tree. * PR feedback: Inject config dependency for purging app cache Previously, `middleware` imported the config back from `store` (i.e. cyclic import). * PR feedback: No need to export config, now that it's passed as argument * Fix tests after refactoring middleware call from array -> function * PR feedback: Let parent continue to pass down initial store state --- app/initial_state.js | 1 - app/reducers/views/root.js | 10 - app/store/middleware.js | 150 ++++++++++++++- app/store/middleware.test.js | 4 +- app/store/store.js | 359 +++++++++++++---------------------- 5 files changed, 279 insertions(+), 245 deletions(-) diff --git a/app/initial_state.js b/app/initial_state.js index 62cd3dc58..2a1abf9fc 100644 --- a/app/initial_state.js +++ b/app/initial_state.js @@ -139,7 +139,6 @@ const state = { root: { deepLinkURL: '', hydrationComplete: false, - purge: false, }, selectServer: { serverUrl: Config.DefaultServerUrl, diff --git a/app/reducers/views/root.js b/app/reducers/views/root.js index e7aaba0a2..6a45aaa6c 100644 --- a/app/reducers/views/root.js +++ b/app/reducers/views/root.js @@ -26,17 +26,7 @@ function hydrationComplete(state = false, action) { } } -function purge(state = false, action) { - switch (action.type) { - case General.OFFLINE_STORE_PURGE: - return true; - default: - return state; - } -} - export default combineReducers({ deepLinkURL, hydrationComplete, - purge, }); diff --git a/app/store/middleware.js b/app/store/middleware.js index 1e09e69b3..2b6c86152 100644 --- a/app/store/middleware.js +++ b/app/store/middleware.js @@ -1,17 +1,147 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Platform} from 'react-native'; import DeviceInfo from 'react-native-device-info'; +import {batchActions} from 'redux-batched-actions'; +import AsyncStorage from '@react-native-community/async-storage'; +import {purgeStoredState} from 'redux-persist'; -import {ViewTypes} from 'app/constants'; +import {NavigationTypes, ViewTypes} from 'app/constants'; import initialState from 'app/initial_state'; +import {throttle} from 'app/utils/general'; + +import {General} from '@mm-redux/constants'; +import {ErrorTypes, GeneralTypes} from '@mm-redux/action_types'; +import EventEmitter from '@mm-redux/utils/event_emitter'; + +import mattermostBucket from 'app/mattermost_bucket'; + +import {getStateForReset} from './utils'; import { captureException, LOGGER_JAVASCRIPT_WARNING, } from 'app/utils/sentry'; -export function messageRetention(store) { +const SAVE_STATE_ACTIONS = [ + 'CONNECTION_CHANGED', + 'DATA_CLEANUP', + 'LOGIN', + 'Offline/STATUS_CHANGED', + 'persist/REHYDRATE', + 'RECEIVED_APP_STATE', + 'WEBSOCKET_CLOSED', + 'WEBSOCKET_SUCCESS', +]; + +// This middleware stores key parts of state entities into a file (in the App Group container) on certain actions. +// iOS only. Allows the share extension to work, without having access available to the redux store object. +// Remove this middleware if/when state is moved to a persisted solution. +const saveShareExtensionState = (store) => { + return (next) => (action) => { + if (SAVE_STATE_ACTIONS.includes(action.type)) { + throttle(saveStateToFile(store)); + } + return next(action); + }; +}; + +const saveStateToFile = async (store) => { + if (Platform.OS === 'ios') { + const state = store.getState(); + + if (state.entities) { + const channelsInTeam = {...state.entities.channels.channelsInTeam}; + Object.keys(channelsInTeam).forEach((teamId) => { + channelsInTeam[teamId] = Array.from(channelsInTeam[teamId]); + }); + + const profilesInChannel = {...state.entities.users.profilesInChannel}; + Object.keys(profilesInChannel).forEach((channelId) => { + profilesInChannel[channelId] = Array.from(profilesInChannel[channelId]); + }); + + let url; + if (state.entities.users.currentUserId) { + url = state.entities.general.credentials.url || state.views.selectServer.serverUrl; + } + + const entities = { + ...state.entities, + general: { + ...state.entities.general, + credentials: { + url, + }, + }, + channels: { + ...state.entities.channels, + channelsInTeam, + }, + users: { + ...state.entities.users, + profilesInChannel, + profilesNotInTeam: [], + profilesWithoutTeam: [], + profilesNotInChannel: [], + }, + }; + + mattermostBucket.writeToFile('entities', JSON.stringify(entities)); + } + } +}; + +const purgeAppCacheWrapper = (persistConfig) => (store) => { + return (next) => (action) => { + if (action.type === General.OFFLINE_STORE_PURGE) { + purgeStoredState({...persistConfig, storage: AsyncStorage}); + + const state = store.getState(); + const resetState = getStateForReset(initialState, state); + + store.dispatch(batchActions([ + { + type: General.OFFLINE_STORE_RESET, + data: resetState, + }, + { + type: ErrorTypes.RESTORE_ERRORS, + data: [...state.errors], + }, + { + type: GeneralTypes.RECEIVED_APP_DEVICE_TOKEN, + data: state.entities.general.deviceToken, + }, + { + type: GeneralTypes.RECEIVED_APP_CREDENTIALS, + data: { + url: state.entities.general.credentials.url, + }, + }, + { + type: ViewTypes.SERVER_URL_CHANGED, + serverUrl: state.entities.general.credentials.url || state.views.selectServer.serverUrl, + }, + { + type: GeneralTypes.RECEIVED_SERVER_VERSION, + data: state.entities.general.serverVersion, + }, + { + type: General.STORE_REHYDRATION_COMPLETE, + }, + ], 'BATCH_FOR_RESTART')); + + setTimeout(() => { + EventEmitter.emit(NavigationTypes.RESTART_APP); + }, 500); + } + return next(action); + }; +}; + +const messageRetention = (store) => { return (next) => (action) => { if (action.type === 'persist/REHYDRATE') { const {app} = action.payload; @@ -55,7 +185,7 @@ export function messageRetention(store) { return next(action); }; -} +}; function resetStateForNewVersion(action) { const {payload} = action; @@ -434,3 +564,17 @@ function removePendingPost(pendingPostIds, id) { pendingPostIds.splice(pendingIndex, 1); } } + +export const middlewares = (persistConfig) => { + const middlewareFunctions = [ + messageRetention, + purgeAppCacheWrapper(persistConfig), + ]; + + if (Platform.OS === 'ios') { + middlewareFunctions.push(saveShareExtensionState); + } + + return middlewareFunctions; +}; + diff --git a/app/store/middleware.test.js b/app/store/middleware.test.js index 55d793097..38f38668a 100644 --- a/app/store/middleware.test.js +++ b/app/store/middleware.test.js @@ -10,10 +10,12 @@ import { cleanUpPostsInChannel, cleanUpState, getAllFromPostsInChannel, - messageRetention, + middlewares, } from 'app/store/middleware'; describe('messageRetention', () => { + const messageRetention = middlewares()[0]; + describe('should chain the same incoming action type', () => { const actions = [ { diff --git a/app/store/store.js b/app/store/store.js index a3a1a65af..fbab8f025 100644 --- a/app/store/store.js +++ b/app/store/store.js @@ -1,29 +1,21 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {batchActions} from 'redux-batched-actions'; -import {Platform} from 'react-native'; import AsyncStorage from '@react-native-community/async-storage'; import {createBlacklistFilter} from 'redux-persist-transform-filter'; import {createTransform, persistStore} from 'redux-persist'; -import {ErrorTypes, GeneralTypes} from '@mm-redux/action_types'; import {General} from '@mm-redux/constants'; import {getConfig} from '@mm-redux/selectors/entities/general'; import configureStore from '@mm-redux/store'; -import EventEmitter from '@mm-redux/utils/event_emitter'; -import {NavigationTypes, ViewTypes} from 'app/constants'; import appReducer from 'app/reducers'; -import {throttle} from 'app/utils/general'; import {getSiteUrl, setSiteUrl} from 'app/utils/image_cache_manager'; import {createSentryMiddleware} from 'app/utils/sentry/middleware'; -import mattermostBucket from 'app/mattermost_bucket'; - -import {messageRetention} from './middleware'; +import {middlewares} from './middleware'; import {createThunkMiddleware} from './thunk'; -import {transformSet, getStateForReset} from './utils'; +import {transformSet} from './utils'; function getAppReducer() { return require('../../app/reducers'); // eslint-disable-line global-require @@ -50,233 +42,140 @@ const setTransforms = [ ...rolesSetTransform, ]; +const viewsBlackListFilter = createBlacklistFilter( + 'views', + ['extension', 'root'], +); + +const typingBlackListFilter = createBlacklistFilter( + 'entities', + ['typing'], +); + +const channelViewBlackList = {loading: true, refreshing: true, loadingPosts: true, retryFailed: true, loadMorePostsVisible: true}; +const channelViewBlackListFilter = createTransform( + (inboundState) => { + const channel = {}; + + for (const channelKey of Object.keys(inboundState.channel)) { + if (!channelViewBlackList[channelKey]) { + channel[channelKey] = inboundState.channel[channelKey]; + } + } + + return { + ...inboundState, + channel, + }; + }, + null, + {whitelist: ['views']}, // Only run this filter on the views state (or any other entry that ends up being named views) +); + +const emojiBlackList = {nonExistentEmoji: true}; +const emojiBlackListFilter = createTransform( + (inboundState) => { + const emojis = {}; + + for (const emojiKey of Object.keys(inboundState.emojis)) { + if (!emojiBlackList[emojiKey]) { + emojis[emojiKey] = inboundState.emojis[emojiKey]; + } + } + + return { + ...inboundState, + emojis, + }; + }, + null, + {whitelist: ['entities']}, // Only run this filter on the entities state (or any other entry that ends up being named entities) +); + +const setTransformer = createTransform( + (inboundState, key) => { + if (key === 'entities') { + const state = {...inboundState}; + for (const prop in state) { + if (state.hasOwnProperty(prop)) { + state[prop] = transformSet(state[prop], setTransforms); + } + } + + return state; + } + + return inboundState; + }, + (outboundState, key) => { + if (key === 'entities') { + const state = {...outboundState}; + for (const prop in state) { + if (state.hasOwnProperty(prop)) { + state[prop] = transformSet(state[prop], setTransforms, false); + } + } + + return state; + } + + return outboundState; + }, +); + +const persistConfig = { + effect: (effect, action) => { + if (typeof effect !== 'function') { + throw new Error('Offline Action: effect must be a function.'); + } else if (!action.meta.offline.commit) { + throw new Error('Offline Action: commit action must be present.'); + } + + return effect(); + }, + persist: (store, options) => { + const persistor = persistStore(store, {storage: AsyncStorage, ...options}, () => { + store.dispatch({ + type: General.STORE_REHYDRATION_COMPLETE, + }); + }); + + store.subscribe(async () => { + const state = store.getState(); + const config = getConfig(state); + + if (getSiteUrl() !== config?.SiteURL) { + setSiteUrl(config.SiteURL); + } + }); + + return persistor; + }, + persistOptions: { + autoRehydrate: { + log: false, + }, + blacklist: ['device', 'navigation', 'offline', 'requests'], + debounce: 500, + transforms: [ + setTransformer, + viewsBlackListFilter, + typingBlackListFilter, + channelViewBlackListFilter, + emojiBlackListFilter, + ], + }, +}; + export default function configureAppStore(initialState) { - const viewsBlackListFilter = createBlacklistFilter( - 'views', - ['extension', 'root'], - ); - - const typingBlackListFilter = createBlacklistFilter( - 'entities', - ['typing'], - ); - - const channelViewBlackList = {loading: true, refreshing: true, loadingPosts: true, retryFailed: true, loadMorePostsVisible: true}; - const channelViewBlackListFilter = createTransform( - (inboundState) => { - const channel = {}; - - for (const channelKey of Object.keys(inboundState.channel)) { - if (!channelViewBlackList[channelKey]) { - channel[channelKey] = inboundState.channel[channelKey]; - } - } - - return { - ...inboundState, - channel, - }; - }, - null, - {whitelist: ['views']}, // Only run this filter on the views state (or any other entry that ends up being named views) - ); - - const emojiBlackList = {nonExistentEmoji: true}; - const emojiBlackListFilter = createTransform( - (inboundState) => { - const emojis = {}; - - for (const emojiKey of Object.keys(inboundState.emojis)) { - if (!emojiBlackList[emojiKey]) { - emojis[emojiKey] = inboundState.emojis[emojiKey]; - } - } - - return { - ...inboundState, - emojis, - }; - }, - null, - {whitelist: ['entities']}, // Only run this filter on the entities state (or any other entry that ends up being named entities) - ); - - const setTransformer = createTransform( - (inboundState, key) => { - if (key === 'entities') { - const state = {...inboundState}; - for (const prop in state) { - if (state.hasOwnProperty(prop)) { - state[prop] = transformSet(state[prop], setTransforms); - } - } - - return state; - } - - return inboundState; - }, - (outboundState, key) => { - if (key === 'entities') { - const state = {...outboundState}; - for (const prop in state) { - if (state.hasOwnProperty(prop)) { - state[prop] = transformSet(state[prop], setTransforms, false); - } - } - - return state; - } - - return outboundState; - }, - ); - - const offlineOptions = { - effect: (effect, action) => { - if (typeof effect !== 'function') { - throw new Error('Offline Action: effect must be a function.'); - } else if (!action.meta.offline.commit) { - throw new Error('Offline Action: commit action must be present.'); - } - - return effect(); - }, - persist: (store, options) => { - const persistor = persistStore(store, {storage: AsyncStorage, ...options}, () => { - store.dispatch({ - type: General.STORE_REHYDRATION_COMPLETE, - }); - }); - - let purging = false; - - // for iOS write the entities to a shared file - if (Platform.OS === 'ios') { - store.subscribe(throttle(() => { - const state = store.getState(); - if (state.entities) { - const channelsInTeam = {...state.entities.channels.channelsInTeam}; - Object.keys(channelsInTeam).forEach((teamId) => { - channelsInTeam[teamId] = Array.from(channelsInTeam[teamId]); - }); - - const profilesInChannel = {...state.entities.users.profilesInChannel}; - Object.keys(profilesInChannel).forEach((channelId) => { - profilesInChannel[channelId] = Array.from(profilesInChannel[channelId]); - }); - - let url; - if (state.entities.users.currentUserId) { - url = state.entities.general.credentials.url || state.views.selectServer.serverUrl; - } - - const entities = { - ...state.entities, - general: { - ...state.entities.general, - credentials: { - url, - }, - }, - channels: { - ...state.entities.channels, - channelsInTeam, - }, - users: { - ...state.entities.users, - profilesInChannel, - profilesNotInTeam: [], - profilesWithoutTeam: [], - profilesNotInChannel: [], - }, - }; - mattermostBucket.writeToFile('entities', JSON.stringify(entities)); - } - }, 1000)); - } - - // check to see if the logout request was successful - store.subscribe(async () => { - const state = store.getState(); - const config = getConfig(state); - - if (getSiteUrl() !== config?.SiteURL) { - setSiteUrl(config.SiteURL); - } - - if (state.views.root.purge && !purging) { - purging = true; - - await persistor.purge(); - - const resetState = getStateForReset(initialState, state); - - store.dispatch(batchActions([ - { - type: General.OFFLINE_STORE_RESET, - data: resetState, - }, - { - type: ErrorTypes.RESTORE_ERRORS, - data: [...state.errors], - }, - { - type: GeneralTypes.RECEIVED_APP_DEVICE_TOKEN, - data: state.entities.general.deviceToken, - }, - { - type: GeneralTypes.RECEIVED_APP_CREDENTIALS, - data: { - url: state.entities.general.credentials.url, - }, - }, - { - type: ViewTypes.SERVER_URL_CHANGED, - serverUrl: state.entities.general.credentials.url || state.views.selectServer.serverUrl, - }, - { - type: GeneralTypes.RECEIVED_SERVER_VERSION, - data: state.entities.general.serverVersion, - }, - { - type: General.STORE_REHYDRATION_COMPLETE, - }, - ], 'BATCH_FOR_RESTART')); - - setTimeout(() => { - purging = false; - EventEmitter.emit(NavigationTypes.RESTART_APP); - }, 500); - } - }); - - return persistor; - }, - persistOptions: { - autoRehydrate: { - log: false, - }, - blacklist: ['device', 'navigation', 'offline', 'requests'], - debounce: 500, - transforms: [ - setTransformer, - viewsBlackListFilter, - typingBlackListFilter, - channelViewBlackListFilter, - emojiBlackListFilter, - ], - }, - }; - const clientOptions = { additionalMiddleware: [ createThunkMiddleware(), createSentryMiddleware(), - messageRetention, + ...middlewares(persistConfig), ], enableThunk: false, // We override the default thunk middleware }; - return configureStore(initialState, appReducer, offlineOptions, getAppReducer, clientOptions); + return configureStore(initialState, appReducer, persistConfig, getAppReducer, clientOptions); }