Skip to main content

Client-Side Storage

Стратегия хранения данных на клиенте (TMA frontend).

Core Principle

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-storageZustand~200 bytessound, vibration, theme, onboardingGuideCompleted✅ Необходим
goloot-hintsZustand~100-500 bytesDismissed contextual hints✅ Необходим
lastKnownBalanceCustom~150 bytesОтслеживание "внешних наград" (scrap, xp, sp)✅ Необходим
goloot-last-visit-timeTimestamp~15 bytesAFK detection для smart refetch (5 min threshold)✅ Необходим
banner_hidden_{id}Dynamic~50 bytes × NСкрытые баннеры (30 min TTL)✅ Необходим
goloot:direct-promo-dismissedFlag~1 bytePermanent dismiss NeutralPromoCodeModal✅ Необходим
referral_rewards_seen_{seasonId}Flag~1 byte × NСкрытие уведомления о реферальных наградах (per season)✅ Необходим
title_notifications_disabledFlag~1 byteОтключение уведомлений о новых тайтлах✅ Необходим
goloot-cmd-hintFlag~1 byteDismissed command hint в QuestGrid✅ Необходим

Total footprint: ~500-1000 bytes (0.5-1 KB) — очень лёгкий.

Deprecated Keys (Removed)

КлючУдалёнПричина
goloot-react-query-cache2025-12-19Переведён на in-memory через bootstrap hydration
goloot-user-storageUnknownLegacy, больше не используется

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,
},
},
});

Стратегия:

  1. При старте → /api/bootstrap загружает всё одним запросом
  2. hydrateBootstrapData() заполняет React Query cache в памяти
  3. Вкладки используют данные из cache без дополнительных запросов
  4. При возврате → 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) + localStorage
  • vibration → Только 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_intro
  • streak_milestone_3
  • raffle_sp_reached_100
  • season_active_info
  • referral_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.tsscrap, xpupdateLastKnownBalancesetQueryData → sync
useAchievementClaim.tsscrap, xp, spupdateLastKnownBalancesetQueryData → sync
useOpenCase.tsscrap, xpaddScrapToCache / addXpToCachedeferred (pendingBalanceCommit)
DailySpinScreen.tsxscrap, xp, spaddScrapToCache / addXpToCachedeferred (pendingBalanceCommit)
useBoostPass.tsscrap, xp, spupdateLastKnownBalancesetQueryData → sync
useRewardClaim.tsscrap, xpupdateLastKnownBalancePartialclaimItem / salvageItem
useRewardSystem.tsscrap, xpvia addScrapToCache / addXpToCachebatch / instant
useApplyRewardGains.tsscrap, xpvia addScrapToCache / addXpToCacheconsolation, promo
useReferrals.tsscrapupdateLastKnownBalancePartialsetQueryData → sync
useStreak.tsspupdateLastKnownBalancePartialsetQueryData → sync
useRaffle.tsspupdateLastKnownBalancePartialsetQueryData → sync
useCraft.tsscrapupdateLastKnownBalancePartialsetQueryData → sync
useInventory.ts (salvage)xpupdateLastKnownBalancePartialsetQueryData → sync
InventoryScreen/index.tsxxpupdateLastKnownBalancePartiallocal state → sync
useSeasonTransition.tsscrap, xp, spupdateLastKnownBalancere-bootstrap

Safety net: useExternalRewardDetection (Effect 2) — централизованный useEffect ловит background refetches (invalidateQueries, refetchOnWindowFocus), которые обновляют React Query cache мимо mutation hooks

Два Effect-а в useExternalRewardDetection

Хук содержит два useEffect, работающих последовательно:

  1. Effect 1 (Detection) — одноразовый. Сравнивает localStorage с профилем, показывает TopFloatingReward при дельте, выставляет checkedRef = true
  2. 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 refetchEffect 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

Зачем:

  • Оптимизация сетевых запросов при возврате в приложение
  • Баланс между актуальностью данных и нагрузкой на сервер

Файл: 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 StatelocalStorageBackend 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);
}

Приоритеты загрузки:

  1. localStorage → Мгновенный старт
  2. Telegram fallback → Для темы если нет localStorage
  3. 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:

  1. Добавление поля — безопасно, просто добавить default value при чтении
  2. Удаление поля — добавить в DEPRECATED_KEYS
  3. Изменение формата — миграция через 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 persistIn-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, привязаны к аккаунту пользователя.

ХарактеристикаCloudStoragelocalStorage
ПривязкаPer-user, per-botPer-device (WebView)
Cross-deviceДаНет
Переживает очистку кешаДаНенадёжно
APIAsync (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

  1. Нужен синхронный доступ — используется в useState initializer и для мгновенного показа баланса при старте
  2. Частая запись — обновляется после каждого действия с балансом (9 хуков: openCase, claimQuest, claimAchievement, redeemPromo, craft и др.)
  3. Cross-device sync вреден — дельта баланса device-specific; sync с другого устройства даст ложную анимацию "+50 scrap"
  4. Питает анимации — 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 далее

Почему не делаем сейчас

  1. Production работает корректно — localStorage проблем у реальных пользователей не вызывает
  2. Проблема воспроизводится только при dev-тестировании — удаление пользователя из БД не очищает localStorage; решается ручной очисткой (см. секцию ниже)
  3. Async API — требует loading state при инициализации хуков, усложняет код
  4. Ограничение символов в ключах — CloudStorage не поддерживает : в ключах, потребуется переименование
  5. 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-storageOnboarding 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));