Client-Side Storage
Стратегия хранения данных на клиенте (TMA frontend).
Hybrid Storage: React Query для server state (in-memory) + Zustand persist для UI preferences (localStorage) + Custom utilities для специфичной логики.
1. Summary
Goal: Обеспечить мгновенный старт приложения без ожидания backend + offline-first UX для некритичных настроек.
Strategy:
- React Query Cache — in-memory (НЕ localStorage) для server state
- Zustand persist — localStorage для UI настроек
- Custom utilities — специфичные use cases (balance tracking, banners)
Migration: 2025-12-19 — React Query cache переведён с localStorage на in-memory через bootstrap hydration.
2. localStorage Keys
Active Keys
| Ключ | Тип | Размер | Назначение | Критичность |
|---|---|---|---|---|
| goloot-settings-storage | Zustand | ~200 bytes | sound, vibration, theme, onboardingGuideCompleted | ✅ Необходим |
| goloot-hints | Zustand | ~100-500 bytes | Dismissed contextual hints | ✅ Необходим |
| lastKnownBalance | Custom | ~150 bytes | Отслеживание "внешних наград" (scrap, xp, sp) | ✅ Необходим |
| goloot-last-visit-time | Timestamp | ~15 bytes | AFK detection для smart refetch (5 min threshold) | ✅ Необходим |
| banner_hidden_{id} | Dynamic | ~50 bytes × N | Скрытые баннеры (30 min TTL) | ✅ Необходим |
| goloot:direct-promo-dismissed | Flag | ~1 byte | Permanent dismiss NeutralPromoCodeModal | ✅ Необходим |
| referral_rewards_seen_{seasonId} | Flag | ~1 byte × N | Скрытие уведомления о реферальных наградах (per season) | ✅ Необходим |
| title_notifications_disabled | Flag | ~1 byte | Отключение уведомлений о новых тайтлах | ✅ Необходим |
| goloot-cmd-hint | Flag | ~1 byte | Dismissed command hint в QuestGrid | ✅ Необходим |
Total footprint: ~500-1000 bytes (0.5-1 KB) — очень лёгкий.
Deprecated Keys (Removed)
| Ключ | Удалён | Причина |
|---|---|---|
| goloot-react-query-cache | 2025-12-19 | Переведён на in-memory через bootstrap hydration |
| goloot-user-storage | Unknown | Legacy, больше не используется |
3. React Query Cache (In-Memory)
До миграции (2025-12-19)
// ❌ СТАРЫЙ подход — persist в localStorage
persistQueryClient({
queryClient,
persister: createSyncStoragePersister({
storage: window.localStorage,
}),
});
Проблемы:
- Большой размер cache в localStorage (до нескольких MB)
- Медленная загрузка при старте
- Конфликты версий cache
- Ошибки десериализации
После миграции
// ✅ НОВЫЙ подход — in-memory + bootstrap hydration
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 минут
gcTime: 30 * 60 * 1000, // 30 минут в памяти
refetchOnWindowFocus: false,
},
},
});
Стратегия:
- При старте →
/api/bootstrapзагружает всё одним запросом hydrateBootstrapData()заполняет React Query cache в памяти- Вкладки используют данные из cache без дополнительных запросов
- При возврате → smart refetch через
useAppVisibility
Преимущества:
- ✅ Быстрый старт (нет десериализации)
- ✅ Нет конфликтов версий
- ✅ Меньше багов
- ✅ Простота отладки
4. Zustand Persist
goloot-settings-storage
Файл: frontend/src/stores/settingsStore.ts
Что хранит:
{
sound: boolean, // БД + localStorage
vibration: boolean, // Только localStorage
theme: 'dark' | 'light', // Только localStorage
onboardingGuideCompleted: boolean // БД + localStorage (кеш)
}
Синхронизация:
sound→ Backend (debounced 500ms) + localStoragevibration→ Только localStorage (нет в БД)theme→ Только localStorage (fallback на Telegram.colorScheme)onboardingGuideCompleted→ Backend + localStorage (кеш для быстрой проверки)
Зачем localStorage:
- Мгновенный старт без ожидания Backend
- Применение темы до загрузки API
- Offline-first UX для некритичных настроек
goloot-hints
Файл: frontend/src/stores/hintsStore.ts
Что хранит:
{
dismissedHints: Record<HintId, {
dismissedAt: string,
viewCount: number
}>
}
Hint IDs:
achievement_introstreak_milestone_3raffle_sp_reached_100season_active_inforeferral_first_activated
Синхронизация:
- Dismissal → Backend сразу (
settingsService.dismissHint()) - localStorage → Кеш для мгновенной проверки
isDismissed()
Зачем localStorage:
- Предотвращает повторный показ hints до загрузки Backend
- Защита от спама подсказками при плохом интернете
5. Custom Utilities
lastKnownBalance
Файл: frontend/src/utils/lastKnownBalance.ts
Что хранит:
{
scrap: number,
xp: number,
sp: number,
spInitialized?: boolean,
seasonNumber?: number,
lastSeen: string
}
Назначение:
- Определение "внешних наград" — наград полученных вне приложения
- При входе в HomeScreen сравнивается текущий баланс с
lastKnownBalance - Если есть дельта → показывается TopFloatingReward
Use Cases:
- Квизы в боте → наградили scrap → пользователь открыл TMA → видит "Вы получили награду!"
- Реферальный бонус → начислен пока TMA был закрыт → показываем уведомление
Обновление (полный список хуков, синкающих lastKnownBalance):
| Хук / Файл | Что меняет | Метод sync | Паттерн |
|---|---|---|---|
useQuestClaim.ts | scrap, xp | updateLastKnownBalance | setQueryData → sync |
useAchievementClaim.ts | scrap, xp, sp | updateLastKnownBalance | setQueryData → sync |
useOpenCase.ts | scrap, xp | addScrapToCache / addXpToCache | deferred (pendingBalanceCommit) |
DailySpinScreen.tsx | scrap, xp, sp | addScrapToCache / addXpToCache | deferred (pendingBalanceCommit) |
useBoostPass.ts | scrap, xp, sp | updateLastKnownBalance | setQueryData → sync |
useRewardClaim.ts | scrap, xp | updateLastKnownBalancePartial | claimItem / salvageItem |
useRewardSystem.ts | scrap, xp | via addScrapToCache / addXpToCache | batch / instant |
useApplyRewardGains.ts | scrap, xp | via addScrapToCache / addXpToCache | consolation, promo |
useReferrals.ts | scrap | updateLastKnownBalancePartial | setQueryData → sync |
useStreak.ts | sp | updateLastKnownBalancePartial | setQueryData → sync |
useRaffle.ts | sp | updateLastKnownBalancePartial | setQueryData → sync |
useCraft.ts | scrap | updateLastKnownBalancePartial | setQueryData → sync |
useInventory.ts (salvage) | xp | updateLastKnownBalancePartial | setQueryData → sync |
InventoryScreen/index.tsx | xp | updateLastKnownBalancePartial | local state → sync |
useSeasonTransition.ts | scrap, xp, sp | updateLastKnownBalance | re-bootstrap |
Safety net: useExternalRewardDetection (Effect 2) — централизованный useEffect ловит background refetches (invalidateQueries, refetchOnWindowFocus), которые обновляют React Query cache мимо mutation hooks
Хук содержит два useEffect, работающих последовательно:
- Effect 1 (Detection) — одноразовый. Сравнивает localStorage с профилем, показывает TopFloatingReward при дельте, выставляет
checkedRef = true - Effect 2 (Continuous Sync) — постоянный. Гейтится через
checkedRef.current— срабатывает только ПОСЛЕ detection. Синкаетscrap/xp/spв localStorage при каждом изменении профиля
Работает благодаря Keep-Alive паттерну: HomeScreen никогда не размонтируется, поэтому Effect 2 ловит все изменения профиля за сессию.
Почему centralized sync, а не queryCache.subscribe?
Проблема: 16+ мест вызывают invalidateQueries(PROFILE_QUERY_KEY), плюс refetchOnWindowFocus: true и refetchOnMount: true. Ни один из этих путей не обновлял localStorage. При следующем входе useExternalRewardDetection находил ложную дельту → показывал повторную анимацию.
Альтернатива (отклонена): queryClient.getQueryCache().subscribe() в AppContent с фильтрацией по query key и event type. Даёт тот же результат, но сложнее: нужен event filtering, парсинг query key, handling mount/unmount.
Решение: Простой useEffect с зависимостью от profile?.scrap, profile?.xp, profile?.streakPoints. При Keep-Alive архитектуре HomeScreen — эквивалентно по покрытию, но проще по реализации (KISS).
Edge Cases:
| Ситуация | Поведение |
|---|---|
| Первый визит (нет localStorage) | Baseline сохраняется, анимация не показывается |
| Tab switch → background refetch | Effect 2 синкает localStorage → нет ложной анимации при следующем входе |
| Mutation + background refetch одновременно | Оба пути синкают localStorage → идемпотентно, stale значений не остаётся |
goloot-last-visit-time
Файл: frontend/src/hooks/useAppVisibility.ts
Что хранит:
timestamp (number as string)
Назначение:
- AFK detection для smart refetch
- Всегда invalidate
profile+quests(высокий приоритет) - Если AFK > 5 минут → дополнительно invalidate
inventory+activeBuffs+season
Зачем:
- Оптимизация сетевых запросов при возврате в приложение
- Баланс между актуальностью данных и нагрузкой на сервер
banner_hidden_{id}
Файл: frontend/src/config/banner.config.ts (константа HIDDEN_PREFIX), используется в frontend/src/components/ui/BannerSlider.tsx
Что хранит:
timestamp (number as string) для каждого скрытого баннера
TTL: 30 минут (HIDE_DURATION = 30 * 60 * 1000)
Назначение:
- Пользователь может закрыть баннер на 30 минут
- После истечения TTL — баннер снова показывается
Автоочистка:
// Проверка TTL при каждом рендере (через safe wrapper)
const hiddenUntil = safeLocalStorage.getItem(hiddenKey);
if (hiddenUntil && Date.now() >= parseInt(hiddenUntil)) {
safeLocalStorage.removeItem(hiddenKey); // TTL истёк
}
6. Data Flow
7. Zustand vs localStorage vs Backend
Разделение ответственности
| Данные | Zustand State | localStorage | Backend DB |
|---|---|---|---|
| sound | ✅ (для UI) | ✅ (кеш) | ✅ (source of truth) |
| vibration | ✅ (для UI) | ✅ (source of truth) | ❌ |
| theme | ✅ (для UI) | ✅ (source of truth) | ❌ |
| onboardingGuideCompleted | ✅ (для UI) | ✅ (кеш) | ✅ (source of truth) |
| dismissedHints | ✅ (для UI) | ✅ (кеш) | ✅ (source of truth) |
| lastKnownBalance | ❌ | ✅ (source of truth) | ❌ |
| lastVisitTime | ❌ | ✅ (source of truth) | ❌ |
| banner_hidden_* | ❌ | ✅ (source of truth) | ❌ |
| goloot:direct-promo-dismissed | ❌ | ✅ (source of truth) | ❌ |
| referral_rewards_seen_* | ❌ | ✅ (source of truth) | ❌ |
| title_notifications_disabled | ❌ | ✅ (source of truth) | ❌ |
| goloot-cmd-hint | ❌ | ✅ (source of truth) | ❌ |
Паттерн синхронизации
// 1. Zustand action обновляет локальное состояние
toggleSound: () => {
set((state) => ({ sound: !state.sound }));
// 2. Zustand persist автоматически сохранит в localStorage
// 3. Debounced sync с Backend (не блокирует UI)
debouncedSaveToBackend(get().saveToBackend);
}
Приоритеты загрузки:
- localStorage → Мгновенный старт
- Telegram fallback → Для темы если нет localStorage
- Backend → Source of truth для критичных настроек
8. Migration & Cleanup
Deprecated Keys Cleanup
Проблема: У существующих пользователей могут остаться ключи из старых версий.
Текущий статус: Cleanup не реализован. Deprecated ключи (goloot-react-query-cache, goloot-user-storage) больше не создаются и не читаются. Они просто остаются в localStorage у старых пользователей без какого-либо эффекта.
При необходимости (если deprecated ключи занимают значительный объём) можно добавить cleanup:
// Рекомендуемый паттерн (не реализован)
const DEPRECATED_KEYS = ['goloot-react-query-cache', 'goloot-user-storage'];
DEPRECATED_KEYS.forEach(key => localStorage.removeItem(key));
Future Migrations
При изменении структуры данных в localStorage:
- Добавление поля — безопасно, просто добавить default value при чтении
- Удаление поля — добавить в DEPRECATED_KEYS
- Изменение формата — миграция через version + transform функцию
Пример версионирования:
// Zustand persist поддерживает версии
{
name: 'goloot-settings-storage',
version: 2, // Инкремент при breaking change
migrate: (persistedState, version) => {
if (version === 1) {
// Миграция с v1 на v2
return { ...persistedState, newField: defaultValue };
}
return persistedState;
}
}
9. Trade-offs & Decisions
Почему НЕ localStorage для React Query?
| Аспект | localStorage persist | In-memory + Bootstrap |
|---|---|---|
| Размер | До нескольких MB | ~80 KB одним запросом |
| Скорость старта | Медленная десериализация | Быстрая гидратация |
| Конфликты версий | Часто | Нет (свежие данные) |
| Stale data | Проблема при изменениях API | Всегда актуальные |
| Отладка | Сложно | Просто (один запрос) |
Решение: In-memory cache + bootstrap hydration для server state.
Почему localStorage для Zustand persist?
| Преимущество | Обоснование |
|---|---|
| Мгновенный старт | Применяем тему до загрузки backend |
| Offline-first | Настройки работают без интернета |
| Лёгкий footprint | ~500 bytes vs MB для React Query |
| Простота | Zustand persist — одна строка кода |
Решение: Zustand persist для UI preferences.
Почему custom utilities?
| Use Case | Почему не Zustand | Почему не React Query |
|---|---|---|
| lastKnownBalance | Нужно вне React (TelegramContext) | Не server state |
| lastVisitTime | Простой timestamp, не нужен store | Не server state |
| banner_hidden_* | Динамические ключи, TTL логика | Не server state |
Решение: Custom utilities для специфичных use cases.
10. Telegram CloudStorage (Future Consideration)
Исследовано в марте 2026. Текущая реализация на localStorage работает корректно в production. Миграция на CloudStorage — возможная оптимизация, но не первостепенная задача.
Что такое CloudStorage
Telegram предоставляет серверное key-value хранилище через window.Telegram.WebApp.CloudStorage (Bot API 6.9+). Данные хранятся на серверах Telegram, привязаны к аккаунту пользователя.
| Характеристика | CloudStorage | localStorage |
|---|---|---|
| Привязка | Per-user, per-bot | Per-device (WebView) |
| Cross-device | Да | Нет |
| Переживает очистку кеша | Да | Ненадёжно |
| API | Async (callback/promise) | Sync |
| Лимиты | 1024 ключей × 4096 символов | ~5-10 MB |
| Символы в ключах | A-Za-z0-9_- (без :) | Любые |
Какие ключи имеет смысл переносить
| Ключ | CloudStorage? | Обоснование |
|---|---|---|
goloot-settings-storage | Да | User preference, sync между устройствами |
goloot-hints | Да | User preference, редко пишется |
direct-promo-dismissed | Да | Per-user флаг, решает проблему при dev-тестировании |
banner_hidden_* | Возможно | Per-user, но TTL 30 мин — не критично |
lastKnownBalance | Нет | Device-local кеш, нужен sync доступ (0ms), частая запись после каждого действия (9 хуков). CloudStorage async (~200ms) — теряется весь смысл кеша |
goloot-last-visit-time | Нет | Device-specific таймер, cross-device sync бессмысленен |
Почему lastKnownBalance не подходит для CloudStorage
- Нужен синхронный доступ — используется в
useStateinitializer и для мгновенного показа баланса при старте - Частая запись — обновляется после каждого действия с балансом (9 хуков: openCase, claimQuest, claimAchievement, redeemPromo, craft и др.)
- Cross-device sync вреден — дельта баланса device-specific; sync с другого устройства даст ложную анимацию "+50 scrap"
- Питает анимации — TopFloatingReward использует
fromValueиз snapshot для счётчика104 → 154
Паттерн миграции (если будет реализован)
// utils/cloudStorage.ts — обёртка с fallback на localStorage
export const cloudStorage = {
get: (key: string): Promise<string | null> =>
new Promise((resolve) => {
const cs = window.Telegram?.WebApp?.CloudStorage;
if (!cs) { resolve(localStorage.getItem(key)); return; }
cs.getItem(key, (err, val) => resolve(err || !val ? null : val));
}),
set: (key: string, value: string): Promise<void> =>
new Promise((resolve) => {
const cs = window.Telegram?.WebApp?.CloudStorage;
if (!cs) { localStorage.setItem(key, value); resolve(); return; }
cs.setItem(key, value, () => resolve());
}),
};
// Чтение при bootstrap (параллельно с API):
// CloudStorage.getItems([...]) → store in memory → sync reads далее
Почему не делаем сейчас
- Production работает корректно — localStorage проблем у реальных пользователей не вызывает
- Проблема воспроизводится только при dev-тестировании — удаление пользователя из БД не очищает localStorage; решается ручной очисткой (см. секцию ниже)
- Async API — требует loading state при инициализации хуков, усложняет код
- Ограничение символов в ключах — CloudStorage не поддерживает
:в ключах, потребуется переименование - ROI низкий — значительная работа ради минимального улучшения UX
11. Dev Testing: localStorage Gotcha
При удалении пользователя из БД (через SQL или админку) — localStorage в Telegram WebView НЕ очищается автоматически. Dismiss-флаги, настройки и кеш баланса остаются от удалённого пользователя.
Симптомы:
- Модалка (NeutralPromoCodeModal, hints) не появляется у "нового" пользователя
- Тема или настройки сохраняются после пересоздания аккаунта
lastKnownBalanceпоказывает баланс удалённого пользователя
Решение: Очистить localStorage вручную перед тестированием:
// В DevTools консоли Telegram WebApp (или eruda):
// Очистить всё goLoot
Object.keys(localStorage)
.filter(k => k.startsWith('goloot') || k.startsWith('banner_hidden_')
|| k.startsWith('referral_rewards_seen_') || k === 'lastKnownBalance'
|| k === 'title_notifications_disabled')
.forEach(k => localStorage.removeItem(k));
// Или полная очистка:
localStorage.clear();
Ключи которые нужно очистить при тестировании нового пользователя:
| Ключ | Эффект если не очистить |
|---|---|
goloot:direct-promo-dismissed | Нейтральная промо-модалка не появится |
goloot-settings-storage | Onboarding guide не покажется (onboardingGuideCompleted: true) |
goloot-hints | Контекстные подсказки не появятся |
lastKnownBalance | Ложная анимация баланса при первом входе |
referral_rewards_seen_{seasonId} | Уведомление о реферальных наградах не появится |
title_notifications_disabled | Уведомления о новых тайтлах останутся отключены |
12. Monitoring & Debugging
Development Tools
// Логирование размера localStorage
export const logLocalStorageSize = () => {
if (process.env.NODE_ENV !== 'development') return;
let totalSize = 0;
const sizes: Record<string, number> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith('goloot') || key?.startsWith('banner_hidden_')
|| key?.startsWith('referral_rewards_seen_') || key === 'lastKnownBalance'
|| key === 'title_notifications_disabled') {
const value = localStorage.getItem(key) || '';
const size = new Blob([value]).size;
sizes[key] = size;
totalSize += size;
}
}
console.log('[localStorage] Total:', totalSize, 'bytes');
console.table(sizes);
};
DevTools Inspection
// Посмотреть все ключи goLoot
Object.keys(localStorage)
.filter(k => k.startsWith('goloot') || k.startsWith('banner_hidden_')
|| k.startsWith('referral_rewards_seen_') || k === 'lastKnownBalance'
|| k === 'title_notifications_disabled')
.forEach(k => console.log(k, localStorage.getItem(k)));
// Очистить всё кроме критичных настроек
Object.keys(localStorage)
.filter(k => !['goloot-settings-storage', 'goloot-hints'].includes(k))
.forEach(k => localStorage.removeItem(k));
Related
- Profile State Management — архитектура управления состоянием профиля (React Query sole source of truth)
- Data Synchronization — стратегия синхронизации с backend
- Overview — общая архитектура системы
- Theme Guide — как работает theme switching
- Telegram Mini Apps CloudStorage — официальная документация API