-
Notifications
You must be signed in to change notification settings - Fork 361
Migrate to use redux-remember instead of redux-persist #4379
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
base: master
Are you sure you want to change the base?
Changes from 10 commits
49b8083
883209b
3aeb275
8ca252b
7d4f155
4b43060
e7fed0e
849037c
119f707
c100af5
b5e8903
fb1c666
9943d9f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { defineRemigrateConfig } from 'redux-remigrate'; | ||
|
|
||
| export default defineRemigrateConfig({ | ||
| storagePath: './src/remigrate', | ||
| stateFilePath: './src/types/state.ts', | ||
| stateTypeExpression: 'State', | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,4 @@ | ||
| import { createStore, applyMiddleware, compose, PreloadedState } from 'redux'; | ||
| import { persistStore } from 'redux-persist'; | ||
| import { applyMiddleware, compose } from 'redux'; | ||
| import thunk from 'redux-thunk'; | ||
| import { setAutoFreeze } from 'immer'; | ||
|
|
||
|
|
@@ -12,16 +11,15 @@ import getLocalStorage from 'storage/localStorage'; | |
| import type { GetState } from 'types/redux'; | ||
| import type { State } from 'types/state'; | ||
| import type { Actions } from 'types/actions'; | ||
| import { configureStore as RTKConfigureStore, StoreEnhancer } from '@reduxjs/toolkit'; | ||
| import { rememberEnhancer } from 'redux-remember'; | ||
| import { migrate } from 'remigrate'; | ||
| import storage from 'storage'; | ||
|
|
||
| // For redux-devtools-extensions - see | ||
| // https://github.com/zalmoxisus/redux-devtools-extension | ||
| const composeEnhancers: typeof compose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; | ||
|
|
||
| // immer uses Object.freeze on returned state objects, which is incompatible with | ||
| // redux-persist. See https://github.com/rt2zz/redux-persist/issues/747 | ||
| // immer uses Object.freeze on returned state objects, which breaks undo history functionality | ||
| setAutoFreeze(false); | ||
|
|
||
| export default function configureStore(defaultState?: State) { | ||
| export default function configureStore(defaultState?: State, usePersistence: boolean = false) { | ||
| // Clear legacy reduxState deprecated by https://github.com/nusmodifications/nusmods/pull/669 | ||
| // to reduce the amount of data NUSMods is using | ||
| getLocalStorage().removeItem('reduxState'); | ||
|
|
@@ -38,37 +36,41 @@ export default function configureStore(defaultState?: State) { | |
| diff: true, | ||
| // Avoid diffing actions that insert a lot of stuff into the state to prevent console from lagging | ||
| diffPredicate: (_getState: GetState, action: Actions) => | ||
| !action.type.startsWith('FETCH_MODULE_LIST') && !action.type.startsWith('persist/'), | ||
| !action.type.startsWith('FETCH_MODULE_LIST') && !action.type.startsWith('@@REMEMBER_'), | ||
| }); | ||
| middlewares.push(logger); | ||
| } | ||
|
|
||
| const storeEnhancer = applyMiddleware(...middlewares); | ||
|
|
||
| const store = createStore( | ||
| rootReducer, | ||
| // Redux typings does not seem to allow non-JSON serialized values in PreloadedState so this needs to be casted | ||
| defaultState as PreloadedState<State> | undefined, | ||
| composeEnhancers(storeEnhancer), | ||
| ); | ||
| const store = RTKConfigureStore({ | ||
| reducer: rootReducer, | ||
| preloadedState: defaultState, | ||
| enhancers: (getDefaultEnhancers) => | ||
| getDefaultEnhancers().concat( | ||
| (usePersistence | ||
| ? compose( | ||
| rememberEnhancer( | ||
| storage, | ||
| ['moduleBank', 'venueBank', 'timetables', 'theme', 'settings', 'planner'], | ||
| { | ||
| migrate, | ||
| serialize: (state, _key) => state, | ||
| unserialize: (state, _key) => state, | ||
| }, | ||
| ), | ||
| storeEnhancer, | ||
| ) | ||
| : storeEnhancer) as StoreEnhancer, | ||
| ), | ||
| }); | ||
|
|
||
| if (module.hot) { | ||
| // Enable webpack hot module replacement for reducers | ||
| module.hot.accept('../reducers', () => store.replaceReducer(rootReducer)); | ||
| } | ||
|
|
||
| // Ask any already-open tab to hand over its current state (including | ||
| // non-persisted slices like undoHistory) so cross-tab undo stays in sync. | ||
| // This must run from the redux-persist bootstrapped callback rather than | ||
| // immediately: persistStore rehydrates asynchronously, and adopting a peer's | ||
| // full snapshot (RECEIVE_INIT_STATE) before local REHYDRATE settles would let | ||
| // a later REHYDRATE overwrite persisted slices (timetables/theme) while | ||
| // leaving the peer's non-persisted undoHistory in place -- pairing the peer | ||
| // undo stack with stale local state. Syncing after bootstrap keeps the | ||
| // adopted snapshot internally consistent. | ||
| const persistor = persistStore(store, undefined, () => { | ||
| initStateWithPrevTab(store); | ||
| }); | ||
| initStateWithPrevTab(store); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That race is fully reintroduced here. If a peer tab responds to Prompt To Fix With AIThis is a comment left during a code review.
Path: website/src/bootstrapping/configure-store.ts
Line: 73
Comment:
**Race condition: cross-tab state sync before rehydration**
`initStateWithPrevTab` is called immediately after store creation, before `REMEMBER_REHYDRATED` has fired. The old code guarded against this with an explicit comment:
> *This must run from the redux-persist bootstrapped callback rather than immediately: [...] adopting a peer's full snapshot (RECEIVE_INIT_STATE) before local REHYDRATE settles would let a later REHYDRATE overwrite persisted slices (timetables/theme) while leaving the peer's non-persisted undoHistory in place — pairing the peer undo stack with stale local state.*
That race is fully reintroduced here. If a peer tab responds to `initStateWithPrevTab` with `RECEIVE_INIT_STATE` before `REMEMBER_REHYDRATED` fires on this tab, the local rehydration will subsequently overwrite the peer's persisted slices (timetables, settings, theme) while leaving the peer's `undoHistory` in place — exactly the broken mixed state the comment described. The fix is to defer the call until `state.reduxRemember.isRehydrated` is `true`, e.g. via a one-shot `store.subscribe` unsubscriber.
How can I resolve this? If you propose a fix, please make it concise. |
||
|
|
||
| return { persistor, store }; | ||
| return store; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { mapValues, omit } from 'lodash'; | ||
| import storage from 'storage'; | ||
|
|
||
| test('redux-persist JSON members should be parsed and _persist should be removed', () => { | ||
| const mockData = { | ||
| maps: {}, | ||
| arrays: [], | ||
| number: 0, | ||
| string: '', | ||
| _persist: true, | ||
| }; | ||
|
|
||
| const mockDataWithStringifiedMembers = mapValues(mockData, JSON.stringify); | ||
|
|
||
| storage.setItem('persist:test_key', mockDataWithStringifiedMembers); | ||
|
|
||
| const recoveredData = storage.getItem('@@remember-test_key'); | ||
| expect(recoveredData).toStrictEqual(omit(mockData, '_persist')); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { mapValues, omit } from 'lodash-es'; | ||
| import { captureException } from 'utils/error'; | ||
|
|
||
| /** | ||
| * Each member in the redux-persist data is stringified, and the entire map is stringified\ | ||
| * Redux-remember format stringifies the data without stringifying each member\ | ||
| * This function takes the redux-persist JSON string and converts it to the redux-remember data format\ | ||
| * @param persistJsonString | ||
| * @returns parsed data | ||
| */ | ||
| const migratePersistToRemember = (persistJsonString: string): any => { | ||
| try { | ||
| const parsedValue = JSON.parse(persistJsonString); | ||
| const data = omit(parsedValue, '_persist'); | ||
| return mapValues(data, JSON.parse); | ||
| } catch (error) { | ||
| captureException(error); | ||
| return null; | ||
| } | ||
| }; | ||
|
|
||
| export default migratePersistToRemember; |
Uh oh!
There was an error while loading. Please reload this page.