🐛 PERSISTENCE: Page refresh resets all game progress #164
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Bug
Refreshing the page completely resets the game progress — nothing seems to get saved.
Investigation Needed
All 7 Zustand stores need auditing for proper
persistmiddleware configuration:useGameStore(coordinator/tick pipeline)useManaStore(mana pools, regen)useCombatStore(spire, combat, spells)useCraftingStore(enchanting, equipment, loot)useAttunementStore(attunement levels & XP)usePrestigeStore(insight, prestige upgrades)useDisciplineStore(disciplines, XP, perks)useUIStore(logs, flags)Each store must use Zustand's
persistmiddleware withlocalStorageand a propernamekey. Also check for any initialization/reset logic insrc/app/page.tsxorsrc/app/layout.tststhat might clear state on load.Files to Check
src/lib/game/stores/*.ts(all store files)src/app/page.tsxsrc/app/layout.tsxsrc/lib/game/utils/safe-persist.tsStarting investigation of persistence bug. Will audit all 8 Zustand stores for proper
persistmiddleware configuration.Persistence Audit Results
Store-by-Store Analysis
persist?name?createSafeStorage?partialize?mana-loop-game-storageinitialized)initializednot persisted — fine (action only)mana-loop-manamana-loop-combatmana-loop-craftingmana-loop-attunementsattunementsmana-loop-prestigemana-loop-discipline-storepracticingCallbacksNOT persisted — correct)mana-loop-ui-storageRoot Cause Identification
All 8 stores DO have
persistmiddleware with propernamekeys andcreateSafeStorage(). ThecreateSafeStorageutility wraps localStorage with try/catch so it won't crash. Thepartializefunctions are correctly configured.However, I found the actual bugs:
createResetGamein gameActions.ts useslocalStorage.removeItemwith wrong keys — it usesmana-loop-prestige-storagebut the actual localStorage key ismana-loop-prestige(no-storagesuffix on some). Let me verify...Actually wait — looking at the store names:
mana-loop-game-storage✅mana-loop-mana❌ (reset usesmana-loop-mana-storage)mana-loop-combat❌ (reset usesmana-loop-combat-storage)mana-loop-prestige❌ (reset usesmana-loop-prestige-storage)mana-loop-crafting❌ (reset usesmana-loop-crafting-storage)mana-loop-attunements❌ (reset usesmana-loop-attunement-storage)mana-loop-discipline-store❌ (not in reset at all!)mana-loop-ui-storage✅ONLY
mana-loop-game-storageandmana-loop-ui-storagematch. The reset function has wrong keys — but this only affects the manual "Reset Game" button, not page refresh.The REAL persistence issue: Zustand persist stores localStorage under the key
"mana-loop-game-storage"etc. But the Zustandpersistmiddleware wraps the value with version metadata. Let me check if there's a hydration/mounting issue...Looking at
page.tsx, the game shows "Loading..." untilmountedis true (client-side hydration), andinitGame()is called in auseEffect. This seems correct — the persist middleware should rehydrate before the first render.But — I notice the persist middleware wraps the entire store in
{version: 1, state: {...}}. The state should be fine after rehydration.Let me check for an issue with
setStatecalls bypassing persist during rehydration. The key issue may be thatuseGameStore.setState(...)inresetGameonly sets local state WITHOUT triggering persist... but actually Zustand persist DOES tracksetStatecalls.The real issue is likely: When Zustand hydrates from localStorage, it calls
set()with the persisted state. ButgameStore.ts'spartializeonly persists 4 fields. Theinitializedfield is not persisted, soinitGame()runs again. BUT —initGame()only setsinitialized: trueand wires callbacks. That's fine.Let me look more carefully at what happens...
AH WAIT — I found it! Looking at
gameActions.tscreateResetGame:But the actual key is
'mana-loop-mana'. This means after a reset, the OLD data stays in localStorage! Zustand loads old data on next page load because the removeItem targets wrong keys.But wait — that's for the manual reset button only. For a page REFRESH...
Let me look more carefully at the Zustand persist behavior. The persist middleware rehydrates state on module import time. Then when the component mounts,
useEffectrunsinitGame(). Theinitializedfield starts asfalse(from initial state since it's not persisted), soinitGame()runs each refresh, which only sets callbacks. That's fine.Actually, I need to test this to confirm. All stores seem correctly configured for persist. The bug description says "page refresh resets everything." Let me check if there's a
versionmismatch causing Zustand to discard state, or if thecreateSafeStoragewrapper has an issue with async getItem returns...WAIT —
safe-persist.tsgetItemreturnsstring | null | Promise<string | null>(mixed types!) but the implementation only returns synchronously. Zustand persist expects either sync or async, not both. This could cause issues on some browsers. But it should still work.I think the core issue is that the persist middleware synchronously rehydrates via
onRehydrateStorage, but the page shows "Loading..." until client mount, and Zustand might not finish rehydration before the firstuseStatesnapshot reads stale initial state.Let me write a regression test to confirm behavior and then fix the identified bugs.
✅ Root Cause Found & Fixed
Primary Bug:
createSafeStoragereturned raw strings instead of parsed objectssrc/lib/game/utils/safe-persist.ts— ThegetItemfunction returned the rawlocalStoragestring, but Zustand's persist middleware expectsgetItemto return the parsed storage envelope{state: any, version: number}.What happened:
storage.setItem(name, {state: {...}, version: 1})→ oursetItemcorrectlyJSON.stringify'd itstorage.getItem(name)→ ourgetItemreturned the raw string'{"state":{...},"version":1}'deserializedStorageValue.versionon a string →undefinedtypeof undefined !== "number", Zustand fell through toreturn [false, deserializedStorageValue.state]→ alsoundefinedoptions.merge(undefined, currentState)=currentState(initial/default state)Fix: Added
JSON.parse(str)ingetItemso Zustand receives the parsed{state, version}object it expects.Secondary Bug:
createResetGameused wrong localStorage keyssrc/lib/game/stores/gameActions.ts— 5 of 8localStorage.removeItemkeys didn't match the actual storenamevalues:mana-loop-mana-storagemana-loop-manamana-loop-combat-storagemana-loop-combatmana-loop-prestige-storagemana-loop-prestigemana-loop-crafting-storagemana-loop-craftingmana-loop-attunement-storagemana-loop-attunementsAfter pressing "Reset Game", stale data remained in localStorage for 5 stores and was reloaded on next page visit.
Fix: Extracted all 8 store keys into a shared
ALL_STORE_KEYSconstant and used it in both the reset function and the persistence tests.Files Changed
src/lib/game/utils/safe-persist.ts—getItemnow returnsJSON.parse(str)instead of raw stringsrc/lib/game/stores/gameActions.ts— Fixed 5 wrong localStorage keys increateResetGamesrc/lib/game/__tests__/persistence.test.ts— New test file (12 tests) covering persistence round-trip, key verification, and reset behaviorTest Results
✅ Fixed and pushed (commit
7279050).Root cause:
createSafeStorage().getItem()returned raw localStorage strings, but Zustand's persist middleware expects parsed{state, version}objects. This caused Zustand to silently ignore persisted data on every page load —deserializedStorageValue.versionwasundefinedon a string, so the migration check failed andstatewas alsoundefined, resulting inoptions.merge(undefined, initialState)= initial state.Secondary fix:
createResetGameused 5 wrong localStorage keys (e.g.,mana-loop-mana-storageinstead ofmana-loop-mana), leaving stale data after manual reset.Files changed:
src/lib/game/utils/safe-persist.ts— getItem now returnsJSON.parse(str)src/lib/game/stores/gameActions.ts— fixed 5 wrong keys using sharedALL_STORE_KEYSconstantsrc/lib/game/__tests__/persistence.test.ts— 12 new testsAll 918 tests pass, zero regressions.