🐛 PERSISTENCE: Page refresh resets all game progress #164

Closed
opened 2026-05-27 17:28:11 +02:00 by Anexim · 4 comments
Owner

Bug

Refreshing the page completely resets the game progress — nothing seems to get saved.

Investigation Needed

All 7 Zustand stores need auditing for proper persist middleware 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 persist middleware with localStorage and a proper name key. Also check for any initialization/reset logic in src/app/page.tsx or src/app/layout.tsts that might clear state on load.

Files to Check

  • src/lib/game/stores/*.ts (all store files)
  • src/app/page.tsx
  • src/app/layout.tsx
  • src/lib/game/utils/safe-persist.ts
## Bug Refreshing the page completely resets the game progress — nothing seems to get saved. ## Investigation Needed All 7 Zustand stores need auditing for proper `persist` middleware 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 `persist` middleware with `localStorage` and a proper `name` key. Also check for any initialization/reset logic in `src/app/page.tsx` or `src/app/layout.tsts` that might clear state on load. ## Files to Check - `src/lib/game/stores/*.ts` (all store files) - `src/app/page.tsx` - `src/app/layout.tsx` - `src/lib/game/utils/safe-persist.ts`
Anexim added the ai:todo label 2026-05-27 17:28:11 +02:00
n8n-gitea was assigned by Anexim 2026-05-27 17:28:11 +02:00
Author
Owner

Starting investigation of persistence bug. Will audit all 8 Zustand stores for proper persist middleware configuration.

Starting investigation of persistence bug. Will audit all 8 Zustand stores for proper `persist` middleware configuration.
Author
Owner

Persistence Audit Results

Store-by-Store Analysis

Store File persist? name? createSafeStorage? partialize? Issue
Game gameStore.ts mana-loop-game-storage 4 fields (NOT initialized) initialized not persisted — fine (action only)
Mana manaStore.ts mana-loop-mana all 4 fields OK
Combat combatStore.ts mana-loop-combat 18 fields OK
Crafting craftingStore.ts mana-loop-crafting 12 fields OK
Attunement attunementStore.ts mana-loop-attunements attunements OK
Prestige prestigeStore.ts mana-loop-prestige 11 fields OK
Discipline discipline-slice.ts mana-loop-discipline-store 5 fields (practicingCallbacks NOT persisted — correct) OK
UI uiStore.ts mana-loop-ui-storage 4 fields OK

Root Cause Identification

All 8 stores DO have persist middleware with proper name keys and createSafeStorage(). The createSafeStorage utility wraps localStorage with try/catch so it won't crash. The partialize functions are correctly configured.

However, I found the actual bugs:

  1. createResetGame in gameActions.ts uses localStorage.removeItem with wrong keys — it uses mana-loop-prestige-storage but the actual localStorage key is mana-loop-prestige (no -storage suffix on some). Let me verify...

    Actually wait — looking at the store names:

    • mana-loop-game-storage
    • mana-loop-mana (reset uses mana-loop-mana-storage)
    • mana-loop-combat (reset uses mana-loop-combat-storage)
    • mana-loop-prestige (reset uses mana-loop-prestige-storage)
    • mana-loop-crafting (reset uses mana-loop-crafting-storage)
    • mana-loop-attunements (reset uses mana-loop-attunement-storage)
    • mana-loop-discipline-store (not in reset at all!)
    • mana-loop-ui-storage

    ONLY mana-loop-game-storage and mana-loop-ui-storage match. The reset function has wrong keys — but this only affects the manual "Reset Game" button, not page refresh.

  2. The REAL persistence issue: Zustand persist stores localStorage under the key "mana-loop-game-storage" etc. But the Zustand persist middleware wraps the value with version metadata. Let me check if there's a hydration/mounting issue...

    Looking at page.tsx, the game shows "Loading..." until mounted is true (client-side hydration), and initGame() is called in a useEffect. 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 setState calls bypassing persist during rehydration. The key issue may be that useGameStore.setState(...) in resetGame only sets local state WITHOUT triggering persist... but actually Zustand persist DOES track setState calls.

    The real issue is likely: When Zustand hydrates from localStorage, it calls set() with the persisted state. But gameStore.ts's partialize only persists 4 fields. The initialized field is not persisted, so initGame() runs again. BUT — initGame() only sets initialized: true and wires callbacks. That's fine.

    Let me look more carefully at what happens...

    AH WAIT — I found it! Looking at gameActions.ts createResetGame:

    localStorage.removeItem('mana-loop-mana-storage');
    

    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, useEffect runs initGame(). The initialized field starts as false (from initial state since it's not persisted), so initGame() 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 version mismatch causing Zustand to discard state, or if the createSafeStorage wrapper has an issue with async getItem returns...

    WAIT — safe-persist.ts getItem returns string | 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 first useState snapshot reads stale initial state.

    Let me write a regression test to confirm behavior and then fix the identified bugs.

## Persistence Audit Results ### Store-by-Store Analysis | Store | File | `persist`? | `name`? | `createSafeStorage`? | `partialize`? | Issue | |-------|------|-----------|---------|---------------------|--------------|-------| | Game | gameStore.ts | ✅ | ✅ `mana-loop-game-storage` | ✅ | ✅ 4 fields (NOT `initialized`) | `initialized` not persisted — fine (action only) | | Mana | manaStore.ts | ✅ | ✅ `mana-loop-mana` | ✅ | ✅ all 4 fields | ✅ OK | | Combat | combatStore.ts | ✅ | ✅ `mana-loop-combat` | ✅ | ✅ 18 fields | ✅ OK | | Crafting | craftingStore.ts | ✅ | ✅ `mana-loop-crafting` | ✅ | ✅ 12 fields | ✅ OK | | Attunement | attunementStore.ts | ✅ | ✅ `mana-loop-attunements` | ✅ | ✅ `attunements` | ✅ OK | | Prestige | prestigeStore.ts | ✅ | ✅ `mana-loop-prestige` | ✅ | ✅ 11 fields | ✅ OK | | Discipline | discipline-slice.ts | ✅ | ✅ `mana-loop-discipline-store` | ✅ | ✅ 5 fields (`practicingCallbacks` NOT persisted — correct) | ✅ OK | | UI | uiStore.ts | ✅ | ✅ `mana-loop-ui-storage` | ✅ | ✅ 4 fields | ✅ OK | ### Root Cause Identification All 8 stores DO have `persist` middleware with proper `name` keys and `createSafeStorage()`. The `createSafeStorage` utility wraps localStorage with try/catch so it won't crash. The `partialize` functions are correctly configured. **However, I found the actual bugs:** 1. **`createResetGame` in gameActions.ts** uses `localStorage.removeItem` with **wrong keys** — it uses `mana-loop-prestige-storage` but the actual localStorage key is `mana-loop-prestige` (no `-storage` suffix on some). Let me verify... Actually wait — looking at the store names: - `mana-loop-game-storage` ✅ - `mana-loop-mana` ❌ (reset uses `mana-loop-mana-storage`) - `mana-loop-combat` ❌ (reset uses `mana-loop-combat-storage`) - `mana-loop-prestige` ❌ (reset uses `mana-loop-prestige-storage`) - `mana-loop-crafting` ❌ (reset uses `mana-loop-crafting-storage`) - `mana-loop-attunements` ❌ (reset uses `mana-loop-attunement-storage`) - `mana-loop-discipline-store` ❌ (not in reset at all!) - `mana-loop-ui-storage` ✅ ONLY `mana-loop-game-storage` and `mana-loop-ui-storage` match. The reset function has wrong keys — but this only affects the manual "Reset Game" button, not page refresh. 2. **The REAL persistence issue**: Zustand persist stores localStorage under the key `"mana-loop-game-storage"` etc. But the Zustand `persist` middleware wraps the value with version metadata. Let me check if there's a hydration/mounting issue... Looking at `page.tsx`, the game shows "Loading..." until `mounted` is true (client-side hydration), and `initGame()` is called in a `useEffect`. 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 `setState` calls bypassing persist during rehydration. The key issue may be that `useGameStore.setState(...)` in `resetGame` only sets local state WITHOUT triggering persist... but actually Zustand persist DOES track `setState` calls. **The real issue is likely**: When Zustand hydrates from localStorage, it calls `set()` with the persisted state. But `gameStore.ts`'s `partialize` only persists 4 fields. The `initialized` field is not persisted, so `initGame()` runs again. BUT — `initGame()` only sets `initialized: true` and wires callbacks. That's fine. Let me look more carefully at what happens... **AH WAIT — I found it!** Looking at `gameActions.ts` `createResetGame`: ``` localStorage.removeItem('mana-loop-mana-storage'); ``` 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, `useEffect` runs `initGame()`. The `initialized` field starts as `false` (from initial state since it's not persisted), so `initGame()` 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 `version` mismatch causing Zustand to discard state, or if the `createSafeStorage` wrapper has an issue with async getItem returns... WAIT — `safe-persist.ts` `getItem` returns `string | 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 first `useState` snapshot reads stale initial state.** Let me write a regression test to confirm behavior and then fix the identified bugs.
Author
Owner

Root Cause Found & Fixed

Primary Bug: createSafeStorage returned raw strings instead of parsed objects

src/lib/game/utils/safe-persist.ts — The getItem function returned the raw localStorage string, but Zustand's persist middleware expects getItem to return the parsed storage envelope {state: any, version: number}.

What happened:

  1. Zustand writes: storage.setItem(name, {state: {...}, version: 1}) → our setItem correctly JSON.stringify'd it
  2. Zustand reads: storage.getItem(name) → our getItem returned the raw string '{"state":{...},"version":1}'
  3. Zustand checked deserializedStorageValue.version on a stringundefined
  4. Since typeof undefined !== "number", Zustand fell through to return [false, deserializedStorageValue.state] → also undefined
  5. options.merge(undefined, currentState) = currentState (initial/default state)
  6. Result: persisted data was silently ignored on every page load

Fix: Added JSON.parse(str) in getItem so Zustand receives the parsed {state, version} object it expects.

Secondary Bug: createResetGame used wrong localStorage keys

src/lib/game/stores/gameActions.ts — 5 of 8 localStorage.removeItem keys didn't match the actual store name values:

Wrong key (old) Correct key (new)
mana-loop-mana-storage mana-loop-mana
mana-loop-combat-storage mana-loop-combat
mana-loop-prestige-storage mana-loop-prestige
mana-loop-crafting-storage mana-loop-crafting
mana-loop-attunement-storage mana-loop-attunements

After 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_KEYS constant and used it in both the reset function and the persistence tests.

Files Changed

  1. src/lib/game/utils/safe-persist.tsgetItem now returns JSON.parse(str) instead of raw string
  2. src/lib/game/stores/gameActions.ts — Fixed 5 wrong localStorage keys in createResetGame
  3. src/lib/game/__tests__/persistence.test.ts — New test file (12 tests) covering persistence round-trip, key verification, and reset behavior

Test Results

  • All 918 tests pass (906 existing + 12 new persistence tests)
  • Zero regressions
## ✅ Root Cause Found & Fixed ### Primary Bug: `createSafeStorage` returned raw strings instead of parsed objects **`src/lib/game/utils/safe-persist.ts`** — The `getItem` function returned the raw `localStorage` string, but Zustand's persist middleware expects `getItem` to return the **parsed** storage envelope `{state: any, version: number}`. **What happened:** 1. Zustand writes: `storage.setItem(name, {state: {...}, version: 1})` → our `setItem` correctly `JSON.stringify`'d it 2. Zustand reads: `storage.getItem(name)` → our `getItem` returned the raw string `'{"state":{...},"version":1}'` 3. Zustand checked `deserializedStorageValue.version` on a **string** → `undefined` 4. Since `typeof undefined !== "number"`, Zustand fell through to `return [false, deserializedStorageValue.state]` → also `undefined` 5. `options.merge(undefined, currentState)` = `currentState` (initial/default state) 6. **Result: persisted data was silently ignored on every page load** **Fix:** Added `JSON.parse(str)` in `getItem` so Zustand receives the parsed `{state, version}` object it expects. ### Secondary Bug: `createResetGame` used wrong localStorage keys **`src/lib/game/stores/gameActions.ts`** — 5 of 8 `localStorage.removeItem` keys didn't match the actual store `name` values: | Wrong key (old) | Correct key (new) | |---|---| | `mana-loop-mana-storage` | `mana-loop-mana` | | `mana-loop-combat-storage` | `mana-loop-combat` | | `mana-loop-prestige-storage` | `mana-loop-prestige` | | `mana-loop-crafting-storage` | `mana-loop-crafting` | | `mana-loop-attunement-storage` | `mana-loop-attunements` | After 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_KEYS` constant and used it in both the reset function and the persistence tests. ### Files Changed 1. **`src/lib/game/utils/safe-persist.ts`** — `getItem` now returns `JSON.parse(str)` instead of raw string 2. **`src/lib/game/stores/gameActions.ts`** — Fixed 5 wrong localStorage keys in `createResetGame` 3. **`src/lib/game/__tests__/persistence.test.ts`** — New test file (12 tests) covering persistence round-trip, key verification, and reset behavior ### Test Results - All **918 tests pass** (906 existing + 12 new persistence tests) - Zero regressions
Author
Owner

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.version was undefined on a string, so the migration check failed and state was also undefined, resulting in options.merge(undefined, initialState) = initial state.

Secondary fix: createResetGame used 5 wrong localStorage keys (e.g., mana-loop-mana-storage instead of mana-loop-mana), leaving stale data after manual reset.

Files changed:

  • src/lib/game/utils/safe-persist.ts — getItem now returns JSON.parse(str)
  • src/lib/game/stores/gameActions.ts — fixed 5 wrong keys using shared ALL_STORE_KEYS constant
  • src/lib/game/__tests__/persistence.test.ts — 12 new tests

All 918 tests pass, zero regressions.

✅ 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.version` was `undefined` on a string, so the migration check failed and `state` was also `undefined`, resulting in `options.merge(undefined, initialState)` = initial state. **Secondary fix:** `createResetGame` used 5 wrong localStorage keys (e.g., `mana-loop-mana-storage` instead of `mana-loop-mana`), leaving stale data after manual reset. **Files changed:** - `src/lib/game/utils/safe-persist.ts` — getItem now returns `JSON.parse(str)` - `src/lib/game/stores/gameActions.ts` — fixed 5 wrong keys using shared `ALL_STORE_KEYS` constant - `src/lib/game/__tests__/persistence.test.ts` — 12 new tests All 918 tests pass, zero regressions.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Anexim/Mana-Loop#164