Enforcing migrations for persisted Redux state using Typescript
We recently came across a bug on a client site that occurred due to type change in our Redux store. No migration was written to reconcile and transform the old state into the new state. The change looked something like this:
// old type
type RootReducer = {
// ...
orderFulfillmentType: 'Pickup' | 'Delivery'
// ...
}
// new type
type RootReducer = {
// ...
orderFulfillmentType: 'pickup' | 'delivery'
// ...
}
Looks pretty reasonable? Assuming you update references to understand the new all lowercase variation of the value you wouldn't expect any issues right? But there were issues... Users who had visited the client's site prior to this change would have their Redux state persisted via
redux-persist
using the Web Storage API. And when the user returns to the site this old Redux state will be loaded back into memory from
window.localStorage
and now our app is in a state that should never exist!
Thankfully
redux-persist
provides the tools to deal with this problem using what it calls versions and migrations. It keeps track of an internal version of your state. If you tell
redux-persist
each time you change the type of your Redux store and provide it a way to transform the old state into the new state (a migration) then it will do this reconcilation before loading the old state in. Et voila! Problem solved. Maybe?
How do we enforce that these migrations and version bumps are done each time the Redux store type changes? Even a code review process will seldom catch an issue like this since the root Redux store type and any type changes below it might not be colocated in the same file. Furthermore, it's not always obvious that a type change should even require a version bump and migration.
The simplest solution we landed on was
a unit test which checks the hash of the Redux store type and compares it against a hash stored in the codebase
If the hash changes but the version is not bumped then the unit test (which runs in our CI/CD pipeline) will not pass and the PR won't get merged! The version number used by
redux-persist
and the type file hash are colocated in the same file so you can't forget to change one without the other. Here's an example of how that unit test might look:
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const reducerConfig = require('./reducer-config')
describe('redux-persist-typed', () => {
it('should require a version bump when the types.ts file changes', async () => {
const currentReducerTypeFileContents = await fs.promises.readFile(
path.join(__dirname, 'types.ts'),
'utf8',
)
const currentReducerTypeHash = (
crypto
.createHash('md5')
.update(currentReducerTypeFileContents)
.digest()
.toString('hex')
)
// Update the "version" and "hash" in reducer-config.js
expect(reducerConfig.hash).toEqual(currentReducerTypeHash)
})
})