Skip to main content

Profile State Management

Архитектура хранения и синхронизации данных профиля на фронтенде.

Scope: Только frontend state management. Backend API — см. Profile.


1. Summary

Goal: Единственный источник правды для данных профиля (балансы, XP, статистика) — React Query cache. Zustand хранит только UI-ephemeral state (анимации наград, уведомления баффов).

User Value: Устранение дубликатов reward-анимаций, мгновенное обновление баланса при мутациях, надёжное отслеживание внешних наград.


2. Problem Statement

Исходная архитектура (Dual Source of Truth)

Данные профиля одновременно хранились в трёх местах:

ХранилищеЧто хранилоПроблема
React Query (['profile'])Server state (GET /profile)Единственный fetch, но не единственный writer
Zustand (useUserStore.profile)Копия server state + addScrap/addXp методыДублирование React Query → рассинхрон
localStorage (lastKnownBalance)Snapshot баланса для обнаружения внешних наградContinuous monitoring → ложные срабатывания

Корневая проблема

HomeScreen запускал непрерывный useEffect, сравнивающий текущий баланс с localStorage на каждом рендере. Мутации внутри приложения (claim квеста, salvage) изменяли баланс → HomeScreen детектировал "дельту" → показывал дубликат reward-анимации, хотя mutation hook уже показал свою.

Симптомы:

  • Двойные TopFloatingReward анимации
  • Stale balance displays
  • Fragile sync logic размазанная по 20+ файлам
  • Hack-функции (markRewardAsShown, wasRewardShownForBalance) для подавления симптомов

3. Target Architecture

┌─────────────────────────────────────────────────────────┐
│ React Query ['profile'] ← SOLE source of truth │
│ - Все чтения через useProfile() или getProfileFromCache│
│ - Все записи через setQueryData (optimistic) │
│ - Server sync через refetch/invalidation │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ Zustand (useUserStore) ← UI-only ephemeral state │
│ - topFloatingReward / rewardQueue │
│ - topFloatingBuff │
│ - pendingReferralBonus │
│ - NO profile data │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ localStorage (lastKnownBalance) ← ONE-TIME startup │
│ - Snapshot баланса после каждой мутации │
│ - Проверка дельты ОДИН РАЗ при запуске приложения │
│ - Не мониторится непрерывно │
└─────────────────────────────────────────────────────────┘

Разделение ответственности

ДанныеReact QueryZustandlocalStorage
scrap, xp, level✅ Source of truthSnapshot
scrapFromQuizzes, etc.✅ Source of truth
topFloatingReward✅ Source of truth
rewardQueue✅ Source of truth
topFloatingBuff✅ Source of truth
pendingReferralBonus✅ Source of truth
lastKnownBalance✅ Source of truth

4. ADR (Architectural Decisions)

Почему React Query — единственный владелец профиля?

Проблема: Дублирование профиля в Zustand приводило к рассинхронизации. Мутации обновляли Zustand → синхронизировали в React Query → обновляли localStorage — 3 шага вместо 1.

Решение: React Query cache как единственное хранилище. Все мутации через setQueryData. Zustand полностью очищен от profile, addScrap, addXp.

Альтернативы (отклонены):

  • Zustand как source of truth — нет built-in stale time, refetch, invalidation
  • Два store в sync — root cause текущих багов, не решение
Критичное правило

Запрещено копировать данные профиля из React Query в Zustand. Это корень проблемы dual source of truth. Zustand хранит ТОЛЬКО ephemeral UI state.

Почему one-time detection вместо continuous?

Проблема: Continuous useEffect на balance changes ловил все изменения — и внутренние мутации, и внешние награды. Отличить их невозможно без хаков.

Решение: Проверка дельты один раз при запуске приложения. Внутренние мутации показывают свои анимации сами.

Последствия:

  • Награды полученные во время работы приложения через внешний канал (quiz bot) — покажутся только при следующем запуске
  • Это приемлемый trade-off: нет дубликатов > мгновенное уведомление

Почему mutation hooks владеют своими анимациями?

Проблема: Централизованный обработчик (HomeScreen useEffect) не знал контекст мутации — источник, тип награды, нужна ли анимация.

Решение: Каждый mutation hook сам вызывает showTopReward() после setQueryData. Никакого middleman.

Паттерн:

Mutation hook → addScrapToCache() → setQueryData + localStorage
→ showTopReward(type, oldValue, newValue)

5. Key Components

Чтение профиля

КомпонентПутьНазначение
useProfilefrontend/src/hooks/useProfile.tsReactive hook для компонентов (обёртка над React Query)
getProfileFromCachefrontend/src/utils/profileCacheUpdater.tsImperative access для callbacks/event handlers

useProfile — для компонентов, которые ре-рендерятся при изменении профиля:

const { profile, isLoading, refetch } = useProfile();

getProfileFromCache — для mutation hooks, где нужен snapshot без подписки:

const current = getProfileFromCache(queryClient);

Запись профиля

КомпонентПутьНазначение
addScrapToCachefrontend/src/utils/profileCacheUpdater.tsДобавить scrap + sync localStorage
addXpToCachefrontend/src/utils/profileCacheUpdater.tsДобавить XP + sync localStorage
setProfileCachefrontend/src/utils/profileCacheUpdater.tsЗаменить весь профиль (из API response)

Каждая функция атомарно:

  1. Обновляет React Query cache через setQueryData
  2. Синхронизирует lastKnownBalance в localStorage
  3. Возвращает новое значение (для reward animation)

Внешние награды

КомпонентПутьНазначение
useExternalRewardDetectionfrontend/src/hooks/useExternalRewardDetection.tsOne-time startup check

Запускается один раз при загрузке HomeScreen:

  1. Читает профиль из React Query cache
  2. Сравнивает с lastKnownBalance из localStorage
  3. Если дельта > 0 → показывает TopFloatingReward
  4. Обновляет lastKnownBalance как новый baseline

UI Ephemeral State (Zustand)

ПолеНазначение
topFloatingRewardТекущая анимация награды (type, from, to)
rewardQueueОчередь наград для последовательного показа
topFloatingBuffУведомление об активации баффа
pendingReferralBonusОжидающий реферальный бонус

Это единственные данные в Zustand useUserStore. Профиль полностью убран.


6. Data Flow

Мутация (quest claim, case open, salvage)

Внешняя награда (quiz bot, referral bonus)


7. Edge Cases

СитуацияПоведениеПочему
Первый запуск (нет localStorage)Сохранить текущий баланс, без анимацииНет baseline для сравнения
Rapid mutations (claim + open case)Каждый hook показывает свою анимацию через rewardQueueQueue обеспечивает последовательность
Внешняя награда во время сессииПокажется при следующем запускеOne-time detection — by design
Tab switch / background returnSmart refetch через useAppVisibilityНе триггерит reward detection
Ошибка мутации (rollback)setQueryData откатывается через onErrorReact Query built-in
useCraft без localStorage syncДобавлен updateLastKnownBalanceБыл баг — дельта накапливалась

8. Removed Hacks

Следующие функции существовали только для подавления continuous detection бага и удалены:

ФункцияБыла вНазначение
createBalanceHash()lastKnownBalance.tsHash баланса для dedup
wasRewardShownForBalance()lastKnownBalance.tsПроверка "показали ли уже"
markRewardAsShown()lastKnownBalance.tsОтметка "показали"
rewardShownForHashLastKnownBalance interfaceХранение hash

С one-time detection они не нужны — каждая мутация показывает свою анимацию, HomeScreen не мониторит.


9. Migration Summary

ШагЧтоРиск
1Создание утилит (useProfile, profileCacheUpdater)Низкий — новые файлы
2Миграция читателей профиля (6 компонентов)Низкий — замена импортов
3Миграция mutation hooks (9 хуков)Высокий — сложная логика
4Удаление profile из Zustand + sync useEffectСредний — TypeScript поймает пропуски
5One-time detection в HomeScreenСредний — критичный UX flow
Порядок выполнения

Шаги выполняются строго последовательно (1→2→3→4→5). Каждый шаг оставляет приложение в рабочем состоянии. Type-check после каждого шага.

Затронутые файлы (~21)

Новые файлы (3):

  • frontend/src/hooks/useProfile.ts
  • frontend/src/utils/profileCacheUpdater.ts
  • frontend/src/hooks/useExternalRewardDetection.ts

Модифицированные файлы (~18):

  • frontend/src/stores/userStore.ts — удаление profile, addScrap, addXp
  • frontend/src/hooks/useUserProfile.ts — удаление Zustand sync
  • frontend/src/hooks/useRewardSystem.ts — замена Zustand → profileCacheUpdater
  • frontend/src/hooks/useRewardClaim.ts — замена Zustand profile access
  • frontend/src/hooks/useQuestClaim.ts — замена snapshot, удаление markRewardAsShown
  • frontend/src/hooks/useAchievementClaim.ts — аналогично useQuestClaim
  • frontend/src/hooks/useReferrals.ts — замена profile access
  • frontend/src/hooks/useOpenCase.ts — localStorage sync в onSuccess
  • frontend/src/hooks/useCraft.ts — добавление missing localStorage sync
  • frontend/src/hooks/usePromoCode.ts — localStorage sync после refetch
  • frontend/src/hooks/useInventory.ts — замена Zustand profile access
  • frontend/src/utils/lastKnownBalance.ts — удаление hack-функций
  • frontend/src/components/screens/HomeScreen/index.tsx — one-time detection
  • frontend/src/components/screens/CasePreviewModal.tsx — useProfile()
  • frontend/src/components/screens/ReferralModal.tsx — useProfile()
  • frontend/src/components/screens/StatisticsScreen.tsx — useProfile()
  • frontend/src/components/screens/RaffleModal.tsx — useProfile()
  • frontend/src/components/screens/InventoryScreen/index.tsx — useProfile()

Без изменений (используют Zustand только для UI state):

  • TopFloatingRewardGlobal.tsxtopFloatingReward
  • ActiveBuffsBar.tsxtopFloatingBuff
  • TelegramContext.tsxsetPendingReferralBonus