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) | ✅ Необходим |
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,
lastSeen: string
}
Назначение:
- Определение "внешних наград" — наград полученных вне приложения
- При входе в HomeScreen сравнивается текущий баланс с
lastKnownBalance - Если есть дельта → показывается TopFloatingReward
Use Cases:
- Квизы в боте → наградили scrap → пользователь открыл TMA → видит "Вы получили награду!"
- Реферальный бонус → начислен пока TMA был закрыт → показываем уведомление
Обновление:
- В
onSuccessвсех мутаций которые меняют баланс - После показа награды в HomeScreen
- После salvage в InventoryScreen
goloot-last-visit-time
Файл: frontend/src/hooks/useAppVisibility.ts
Что хранит:
timestamp (number as string)
Назначение:
- AFK detection для smart refetch
- Если пользователь AFK < 5 минут → invalidate только
profile - Если AFK > 5 минут → invalidate
profile+inventory+activeBuffs+season
Зачем:
- Оптимизация сетевых запросов при возврате в приложение
- Баланс между актуальностью данных и нагрузкой на сервер
banner_hidden_{id}
Файл: frontend/src/components/ui/BannerSlider.tsx
Что хранит:
timestamp (number as string) для каждого скрытого баннера
TTL: 30 минут (HIDE_DURATION = 30 * 60 * 1000)
Назначение:
- Пользователь может закрыть баннер на 30 минут
- После истечения TTL — баннер снова показывается
Автоочистка:
// Проверка TTL при каждом рендере
const hiddenUntil = localStorage.getItem(hiddenKey);
if (hiddenUntil && Date.now() >= parseInt(hiddenUntil)) {
localStorage.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) | ❌ |
Паттерн синхронизации
// 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. 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 === 'lastKnownBalance') {
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 === 'lastKnownBalance')
.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