Skip to content

Sync storage engines, nested rehydrate after purge, frozen state, key-change warning - #1522

Merged
rt2zz merged 7 commits into
masterfrom
fix-storage-robustness
Oct 7, 2026
Merged

rt2zz merged 7 commits into
masterfrom
fix-storage-robustness

Conversation

@rt2zz

@rt2zz rt2zz commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1397, closes #1281, closes #1298.

Stacked on #1521 (it touches the same REHYDRATE branch); merge that first. One change per commit:

  • Storage engines can return values instead of promises (Persistoid crashes in react-native testing library #1397, Synchronous store (no async/await Promises, react-native-mmkv) #1281). persistStore called .then on whatever getItem returned, so a synchronous engine or a Jest mock (jest.fn() returns undefined) crashed with "Cannot read properties of undefined (reading 'then')". Each storage call is now wrapped in a promise: a value, a promise and a throw all work, and a throw is handled like a failed read or write. setItem is still called synchronously, so flush() in beforeunload keeps writing right away.
  • A purged parent passes nested REHYDRATE through. After persistor.purge(), a persistReducer ignored REHYDRATE for every key, not just its own, and didn't pass it on. So a nested persistReducer whose read finished after the purge (logging out right at startup) never rehydrated and never saved again. So did one added later with code splitting. Found while checking After purge it stops persisting #883 against v7; v6 had it too.
  • A read that finishes after a purge brings nothing back (from review of the previous commit). Without this, the late REHYDRATE passing through could make a parent that also stores the child save its whole pre-purge state, and plain reducers reading that REHYDRATE got the purged payload. Such a read now delivers no payload, and a purged store passing a nested REHYDRATE through doesn't save.
  • A read that rejects without a reason is a failed read (older bug, found in review). reject() with no error was taken for empty storage, so initial state was saved over the stored data. An engine in our community list does this. Same for migrations. A callback-style getItem(key, cb) that returns nothing is now a failed read with a clear message too, instead of reading as empty storage.
  • Frozen in, frozen out, from startup (Incorrect work with immer immutable state first level. #1298). With Redux Toolkit (immer), every level of state was frozen except the top-level object persistReducer creates to add _persist. It's now frozen when the reducer's state, or the state persistReducer was given, is frozen. Unfrozen state is untouched.
  • Warning when replaceReducer changes the storage key (Dynamic keyPrefix for Multiple Users #1112). Swapping in a persistReducer with another key/keyPrefix (a common attempt at per-user storage) keeps the current state and writes it over what's stored under the new key, without loading anything. That's now a development error pointing to a new store and persistor; the docs PR has that recipe. Hot reloading with the same key doesn't warn.

Tests: storageValues.spec.ts, purgeNested.spec.ts, frozenState.spec.ts, replaceKey.spec.ts. Each fails without its commit (except the "doesn't freeze" and "doesn't warn" guards). 157 tests pass across repeated runs.

An adversarial review ran scenario scripts covering:

  • write ordering
  • sync throws
  • the community storage engines' signatures
  • devtools replays
  • RTK and redux-undo
  • nested persists
  • the warning's false positives

The purge, rejection and freeze commits address what it found. Known limits, not fixed here:

  • swapping in a different storage instance under the same key isn't detected
  • changing key while Redux DevTools replays stops saving silently, though stored data stays intact

No dependency changes.

rt2zz added 4 commits October 7, 2026 12:00
…1281)

persistStore called .then on whatever getItem returned, and the write path
called .catch on setItem's result, so a synchronous storage engine, or a
test mock like jest.fn() that returns undefined, crashed with "Cannot read
properties of undefined (reading 'then')". Wrap each storage call in a
promise: a returned value, a promise and a thrown error all work, and a
throw is handled like a failed read or write. setItem is still called
synchronously, so flush() in beforeunload keeps writing right away.
After persistor.purge(), persistReducer ignores REHYDRATE so purged data
doesn't come back, but it ignored REHYDRATE for every key, not just its
own, and didn't pass it on. A nested persistReducer whose read finished
after the purge (logging out right at startup), or one added later with
code splitting, never saw its REHYDRATE: it never rehydrated, so it never
saved again. Ignore only this key's REHYDRATE and pass the rest through.
persistReducer adds _persist by returning a new top-level object, so with
Redux Toolkit (immer), which freezes the state it produces, every level was
frozen except the top one, and assigning to a top-level key silently
worked. When the reducer's state is frozen, freeze the object that adds
_persist too. Unfrozen state is left as is.
Swapping in a persistReducer with another key or keyPrefix through
replaceReducer (a common attempt at per-user storage) keeps the current
state and starts writing it under the new key, replacing what's stored
there; nothing is loaded from it. Log a development error when that
happens, pointing to a new store and persistor instead. Hot reloading with
the same key is unaffected.
rt2zz added 3 commits October 7, 2026 13:03
From review of the previous commit: once a purged parent passes a nested
persistReducer's REHYDRATE through, a read that started before the purge
could restore the purged data. The nested REHYDRATE gave the parent a new
child _persist, so the parent saved its whole pre-purge state (when it
also stores the child); and plain reducers reading that REHYDRATE got the
purged payload. Logging out with purge() and reloading right away left the
old data in storage.

A read that finishes after its store was purged now delivers no payload,
and a purged store passing a nested REHYDRATE through doesn't save; the
next real change saves as usual.
A storage read rejected with no reason (an engine calling reject(), or,
since the previous commits, throwing null) reached the read-failure path
with err undefined, so it was taken for empty storage and initial state
was saved over the stored data. Substitute an error, for reads and for
migrations.

Also, a getItem(key, callback) that returns nothing is a callback-style
engine, which isn't supported; returning nothing used to crash on .then,
and since storage values are accepted it would have read as empty storage.
It's now a failed read with a message saying so.
From review: the previous commit froze the top level only once the
reducer produced frozen state, i.e. after the first action a slice
handled. persistReducer passes its reducer a copy without _persist, which
Redux Toolkit returns as is when no case reducer matches, and the state
reconcilers return copies too. Also check whether the state persistReducer
was given is frozen, and use the same helper for the purged REHYDRATE
branch.
@rt2zz
rt2zz changed the base branch from fix-devtools-replay to master October 7, 2026 20:56
@rt2zz
rt2zz merged commit e509a7b into master Oct 7, 2026
3 checks passed
@rt2zz
rt2zz deleted the fix-storage-robustness branch October 7, 2026 21:11
@rt2zz rt2zz mentioned this pull request Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant