Skip to content

Migrate to use redux-remember instead of redux-persist#4379

Draft
zehata wants to merge 13 commits into
nusmodifications:masterfrom
zehata:redux-remember
Draft

Migrate to use redux-remember instead of redux-persist#4379
zehata wants to merge 13 commits into
nusmodifications:masterfrom
zehata:redux-remember

Conversation

@zehata

@zehata zehata commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Context

redux-persist is last committed to in 2021.
Objectively speaking, this is adding technical debt dating back to 2020: 4b484cf.

// FIXME: Remove the next line when _persist is optional again.
// Cause: https://github.com/rt2zz/redux-persist/pull/919
// Issue: https://github.com/rt2zz/redux-persist/pull/1170
// eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain

// FIXME: Remove the next line when _persist is optional again.
// Cause: https://github.com/rt2zz/redux-persist/pull/919
// Issue: https://github.com/rt2zz/redux-persist/pull/1170
// eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain

// FIXME: Remove the next line when _persist is optional again.
// Cause: https://github.com/rt2zz/redux-persist/pull/919
// Issue: https://github.com/rt2zz/redux-persist/pull/1170
// eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain

With the push to move towards agentic involvement, #4314, there may be a need to migrate over to a more recent solution.

To be quite honest, I didn't spend too long looking for an alternative, and redux-remember is the only one I have found. You should try looking for alternatives to redux-persist and redux-remember to decide if this is a prudent replacement.

Implementation

WIP

Note that I am not removing the redux-persist key-values in localStorage, just in case something goes wrong and we need to rollback. It will be trivial to remove in a future PR, perhaps along with the migration code.

Other Information

Obviously, with such a fundamental change to how we are storing user data, I think it is only prudent if we wait until the end of the semester.

@vercel

vercel Bot commented Mar 31, 2026

Copy link
Copy Markdown

@zehata is attempting to deploy a commit to the modsbot's projects Team on Vercel.

A member of the Team first needs to authorize it.

@codecov

codecov Bot commented Mar 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 56.57895% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.34%. Comparing base (988c6fd) to head (9943d9f).
⚠️ Report is 258 commits behind head on master.

Files with missing lines Patch % Lines
website/src/reducers/timetables.ts 42.85% 8 Missing ⚠️
website/src/storage/RehydrateGate.tsx 0.00% 6 Missing ⚠️
website/src/bootstrapping/configure-store.ts 55.55% 4 Missing ⚠️
website/src/reducers/reduxRemember.ts 42.85% 4 Missing ⚠️
website/src/storage/index.ts 72.72% 3 Missing ⚠️
...e/src/bootstrapping/migrate-persist-to-remember.ts 71.42% 2 Missing ⚠️
website/src/entry/App.tsx 0.00% 2 Missing ⚠️
website/src/entry/main.tsx 0.00% 2 Missing ⚠️
website/src/entry/export/main.tsx 0.00% 1 Missing ⚠️
website/src/reducers/moduleBank.ts 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4379      +/-   ##
==========================================
+ Coverage   54.52%   58.34%   +3.81%     
==========================================
  Files         274      319      +45     
  Lines        6076     7312    +1236     
  Branches     1455     1803     +348     
==========================================
+ Hits         3313     4266     +953     
- Misses       2763     3046     +283     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@zehata
zehata marked this pull request as draft March 31, 2026 09:34
@zehata
zehata marked this pull request as ready for review March 31, 2026 12:31
zehata added 3 commits May 23, 2026 18:23
- Previous state during rehydration is not the default state, but rather the rehydrated state itself
@vercel

vercel Bot commented May 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
nusmods-export Ignored Ignored Preview May 24, 2026 8:06am
nusmods-website Ignored Ignored Preview May 24, 2026 8:06am

Request Review

@ravern

ravern commented May 24, 2026

Copy link
Copy Markdown
Member

@greptileai review

@greptile-apps

greptile-apps Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces redux-persist (unmaintained since 2021) with redux-remember + RTK configureStore, keeping the same set of persisted slices and adding a migration path so existing users' localStorage data is automatically converted on first load.

  • Core store wiring: rememberEnhancer is composed with applyMiddleware; rememberReducer wraps combineReducers; a new reduxRemember slice tracks isRehydrated/isPersisted so RehydrateGate can gate rendering.
  • Migration: getItem in the storage adapter falls back to the old persist: key when the @@remember- key is absent, parsing and de-nesting the redux-persist format via migratePersistToRemember.
  • Reducers: All persistConfig and createMigrate scaffolding is removed; REHYDRATE handlers are replaced with REMEMBER_REHYDRATED cases that operate on state (already merged by rememberReducer) or action.payload for cross-slice reads like timetables.

Confidence Score: 4/5

Safe to merge for single-tab users; multi-tab users who open a second tab while the first is active can land in a mixed state where undo history comes from the peer tab but timetables/settings come from local storage.

The cross-tab state sync guard that was explicitly documented and maintained in the old persistStore callback was removed without replacement. initStateWithPrevTab is now called before REMEMBER_REHYDRATED fires, so a fast-responding peer tab can set persisted slices that are subsequently overwritten by the local rehydration, leaving undoHistory paired with stale timetable data. Everything else in the migration — the storage adapter fallback, migratePersistToRemember, the new reduxRemember slice, and RehydrateGate — looks correct.

website/src/bootstrapping/configure-store.ts — the initStateWithPrevTab call needs to be deferred until after rehydration.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser as Browser (new tab)
    participant Store as Redux Store
    participant RE as rememberEnhancer
    participant LS as localStorage
    participant Peer as Peer Tab

    Browser->>Store: configureStore(undefined, true)
    Store->>RE: attach enhancer
    RE-->>LS: "schedule async read (@@remember-* keys)"
    Store->>Browser: initStateWithPrevTab(store) [immediate]
    Browser->>Peer: broadcast: send me your state
    Note over RE,LS: async read in flight
    Peer-->>Browser: RECEIVE_INIT_STATE (peer timetables + undoHistory)
    Browser->>Store: dispatch RECEIVE_INIT_STATE adopt peer state
    LS-->>RE: localStorage data returned
    RE->>Store: dispatch REMEMBER_REHYDRATED(localStorageState)
    Store->>Store: timetables reducer overwrites peer timetables with local data
    Note over Store: undoHistory = peer's, timetables = local = inconsistent state
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser as Browser (new tab)
    participant Store as Redux Store
    participant RE as rememberEnhancer
    participant LS as localStorage
    participant Peer as Peer Tab

    Browser->>Store: configureStore(undefined, true)
    Store->>RE: attach enhancer
    RE-->>LS: "schedule async read (@@remember-* keys)"
    Store->>Browser: initStateWithPrevTab(store) [immediate]
    Browser->>Peer: broadcast: send me your state
    Note over RE,LS: async read in flight
    Peer-->>Browser: RECEIVE_INIT_STATE (peer timetables + undoHistory)
    Browser->>Store: dispatch RECEIVE_INIT_STATE adopt peer state
    LS-->>RE: localStorage data returned
    RE->>Store: dispatch REMEMBER_REHYDRATED(localStorageState)
    Store->>Store: timetables reducer overwrites peer timetables with local data
    Note over Store: undoHistory = peer's, timetables = local = inconsistent state
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
website/src/bootstrapping/configure-store.ts:73
**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.

Reviews (4): Last reviewed commit: "Merge branch 'master' into redux-remembe..." | Re-trigger Greptile

Comment thread website/src/entry/App.tsx Outdated
Comment thread website/src/reducers/timetables.ts
Comment thread website/src/entry/App.tsx Outdated
Comment thread website/src/bootstrapping/configure-store.ts
@ravern

ravern commented May 24, 2026

Copy link
Copy Markdown
Member

@greptileai review

@zehata

zehata commented May 24, 2026

Copy link
Copy Markdown
Contributor Author

Sorry, it's been a while since I worked on this, but if I remember correctly there was some issue related to #4383 that is blocking this. If I remember correctly, there was an edge case where the rehydrate actions can contain a function in the object which causes it serialization to fail.

I should also mention, this was written awhile back when the repo first moved to using vitest, and a lint message had made me think that the repo moved to React Compiler, which is the reason why I left out the memo and callback.

@ravern

ravern commented May 24, 2026

Copy link
Copy Markdown
Member

Thanks, sorry for walking all over your PR - I thought it was about ready to merge + it is the end of semester. Usually for these kinds of PRs I just fix the small issues then merge.

If you prefer I can reset this to before I made any changes.

Given that this PR is blocked, you prefer to re-do this migration in another PR or leave this here till we confirm a solution in #4383?

@zehata

zehata commented May 24, 2026

Copy link
Copy Markdown
Contributor Author

The behavioral changes look fine actually, and I really should have changed it before but I was working on something else. I was planning to take a look through again. There were some refactoring that I was thinking might need to be done, including moving the rehydrate gate component to maybe a file together with the rest of the store logic.

Let me quickly check through it, I actually meant for this to be done before #4387 so that we can use remigrate's migration logic, so it's actually better to get this done first.

Comment thread website/src/entry/App.tsx Outdated

const App: FC<PropsWithChildren<Props>> = ({ store }) => {
const onBeforeLift = () => {
const onBeforeLift = React.useCallback(() => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There is nothing wrong with making this a callback but doing so is actually redundant. App never gets rerendered, I think. I added a console log and it only gets logged on initial page load.

zehata added 2 commits May 25, 2026 11:31
- With changing `composeEnhancers` to `compose` in `configureStore.ts` which enables the RTK extension by default, there is no longer a need for this flag.
- I was thinking about whether we should document the function of rehydrategate to point to https://redux-remember.js.org/usage/rehydration-gate/ but I think it should be obvious enough? Let me know whether to add it
@zehata

zehata commented May 25, 2026

Copy link
Copy Markdown
Contributor Author

Given that this PR is blocked, you prefer to re-do this migration in another PR or leave this here till we confirm a solution in #4383?

@ravern Yea, let's. I don't mind resolving the merge conflicts once #4383 is fixed, do you prefer if I rebase this onto the fixed trunk? I won't delete the branch just yet, of course, do you want to close this PR now or KIV it?

Don't worry, it's not blocking me. I'm actually working on #4387 (reply in thread) so this can wait

@zehata

zehata commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

I am resolving the conflicts

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

T-Rex pricing update — T-Rex was free through June 2026. Effective July 1, 2026, T-Rex adds 2 credits on top of the standard 1-credit review (3 total). T-Rex settings

const persistor = persistStore(store, undefined, () => {
initStateWithPrevTab(store);
});
initStateWithPrevTab(store);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 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.

Prompt To Fix With AI
This 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.

@zehata
zehata marked this pull request as draft July 3, 2026 04:37
…d `initStateWithPrevTab` by calling `initStateWithPrevTab` during unserialization
@zehata

zehata commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@ravern I'm a bit confused about the call chain of the actions because I am not exactly sure how redux-state-sync is interacting with redux-persist
If I understand correctly, in

const persistor = persistStore(store, undefined, () => {
initStateWithPrevTab(store);
});

the persistStore accepts the callback () => initStateWithPrevTab(store) which then gets ran in redux-persist/persistStore as boostrappedCb() after rehydration

export default function persistStore(
  store: Object,
  options?: ?PersistorOptions,
  cb?: BoostrappedCb
): Persistor {
    // ...
    // redacted for brevity
    // ...
    // dispatch to `store` to rehydrate and `persistor` to track result
    store.dispatch(rehydrateAction)
    _pStore.dispatch(rehydrateAction)
    if (boostrappedCb && persistor.getState().bootstrapped) {
      boostrappedCb()
      boostrappedCb = false
    }

With the change to redux-remember, if my understanding is correct, in order to maintain the same behavior we gate this behind RehydrateGate's onBeforeLift.

@zehata

zehata commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Also, I will be closing this PR, rebasing this branch and opening this as a separate PR to just clean up the history

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants