Merge PR #483: сдвиг страницы, мигание переключателя языка и перезапуск фона при SPA-навигации

fix(ui): сдвиг страницы, мигание переключателя языка и перезапуск фона при SPA-навигации
This commit is contained in:
Egor
2026-07-13 15:01:12 +03:00
committed by GitHub
5 changed files with 79 additions and 18 deletions

View File

@@ -31,6 +31,7 @@ import {
ServiceUnavailableScreen,
} from './components/blocking';
import { ErrorBoundary } from './components/ErrorBoundary';
import { BackgroundHost } from './components/backgrounds/BackgroundHost';
import { PermissionRoute } from '@/components/auth/PermissionRoute';
import { saveReturnUrl } from './utils/token';
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
@@ -259,6 +260,8 @@ function App() {
return (
<>
{/* Живёт над <Routes>: анимация фона не перезапускается при навигации */}
<BackgroundHost />
<BlockingOverlay />
<Routes>
{/* Public routes */}

View File

@@ -1,25 +1,23 @@
import { useTranslation } from 'react-i18next';
import { useState, useRef, useEffect } from 'react';
import { infoApi, type LanguageInfo } from '@/api/info';
import { useQuery } from '@tanstack/react-query';
import { infoApi } from '@/api/info';
import { ChevronDownIcon } from '@/components/icons';
export default function LanguageSwitcher() {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [availableLanguages, setAvailableLanguages] = useState<LanguageInfo[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fetchLanguages = async () => {
try {
const data = await infoApi.getLanguages();
setAvailableLanguages(data.languages);
} catch {
// Silently fall back to empty list — component handles it gracefully
}
};
fetchLanguages();
}, []);
// Кэш react-query переживает перемонтирование AppShell при смене роута:
// локальный useState начинал каждый маунт с пустого списка, и до ответа API
// компонент рендерил null — переключатель «мигал» при каждой навигации.
const { data } = useQuery({
queryKey: ['languages'],
queryFn: infoApi.getLanguages,
staleTime: 1000 * 60 * 5,
});
const availableLanguages = data?.languages ?? [];
const currentLang = availableLanguages.find((l) => l.code === i18n.language) ||
availableLanguages[0] || { code: 'ru', name: 'RU', flag: '🇷🇺' };

View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
import { create } from 'zustand';
import { BackgroundRenderer } from './BackgroundRenderer';
/**
* Держит анимированный фон смонтированным ПОВЕРХ смены роутов.
*
* Каждый роут оборачивается в собственный <Layout>, поэтому AppShell
* перемонтируется при каждой навигации — если рендерить BackgroundRenderer
* прямо из AppShell, анимация фона стартует заново на каждой странице.
*
* Вместо этого AppShell лишь регистрирует своё присутствие через
* useBackgroundConsumer(), а BackgroundHost живёт в App (над <Routes>, не
* перемонтируется) и держит один экземпляр BackgroundRenderer, пока есть хоть
* один потребитель. Страницы без AppShell (логин, QuickPurchase со своим
* экземпляром) фон, как и раньше, не получают.
*/
const useBackgroundConsumers = create<{ count: number }>(() => ({ count: 0 }));
/** Вызывается из AppShell: «на этом роуте должен быть анимированный фон». */
export function useBackgroundConsumer() {
useEffect(() => {
useBackgroundConsumers.setState((s) => ({ count: s.count + 1 }));
return () => useBackgroundConsumers.setState((s) => ({ count: s.count - 1 }));
}, []);
}
export function BackgroundHost() {
const active = useBackgroundConsumers((s) => s.count > 0);
const [mounted, setMounted] = useState(active);
useEffect(() => {
if (active) {
setMounted(true);
return;
}
// Грейс-период на случай, когда между размонтированием старого AppShell и
// маунтом нового есть зазор (StrictMode, Suspense): без него фон моргал бы
// и на таких переходах.
const timer = setTimeout(() => setMounted(false), 300);
return () => clearTimeout(timer);
}, [active]);
return mounted ? <BackgroundRenderer /> : null;
}

View File

@@ -39,7 +39,7 @@ import {
import { MobileBottomNav } from './MobileBottomNav';
import { AppHeader } from './AppHeader';
import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer';
import { useBackgroundConsumer } from '@/components/backgrounds/BackgroundHost';
interface AppShellProps {
children: React.ReactNode;
@@ -60,6 +60,9 @@ export function AppShell({ children }: AppShellProps) {
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags();
useScrollRestoration();
// Анимированный фон рендерит BackgroundHost в App (не перемонтируется при
// смене роута) — здесь только регистрируем, что на этом роуте он нужен.
useBackgroundConsumer();
// Theme toggle visibility
const { data: enabledThemes } = useQuery({
@@ -180,9 +183,6 @@ export function AppShell({ children }: AppShellProps) {
return (
<div className="min-h-viewport">
{/* Animated background renders via portal on document.body at z-index: -1 */}
<BackgroundRenderer />
{/* Global components */}
<WebSocketNotifications />
<CampaignBonusNotifier />
@@ -190,7 +190,11 @@ export function AppShell({ children }: AppShellProps) {
<PromptDialogHost />
{/* Desktop Header */}
<header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/95 lg:block">
{/* w-screen вместо left-0 right-0: right-0 упирается в край вьюпорта БЕЗ
скроллбара, и капсула по центру прыгала бы на полширины скроллбара при
переходах между страницами со скроллом и без. 100vw даёт ту же ось
центрирования, что и у body (тоже 100vw). */}
<header className="fixed left-0 top-0 z-50 hidden w-screen border-b border-dark-800/50 bg-dark-950/95 lg:block">
{/* 3-зонный grid: лого | капсула | действия. Колонки 1fr_auto_1fr держат
капсулу строго по центру вьюпорта НЕЗАВИСИМО от ширины лого/действий,
а действия — у правого края. Поэтому ничего не «скачет» при переходах

View File

@@ -235,6 +235,17 @@
scrollbar-color: rgb(var(--color-dark-700)) transparent;
}
body {
/* 100vw включает ширину скроллбара, поэтому центрированный контент не
сдвигается, когда при навигации появляется/исчезает вертикальный
скроллбар; лишние ~6px справа уходят под скроллбар и срезаются
overflow-x: hidden на html. Вариант со scrollbar-gutter: stable не
подошёл: фоновый слой (fixed-портал) не красит зарезервированный жёлоб,
и справа остаётся пустая полоса. На мобильных overlay-скроллбары места
не занимают — правило ни на что не влияет. */
width: 100vw;
}
/* Hide scrollbar on mobile (iOS/Android) */
@media (max-width: 1023px) {
html,