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

View File

@@ -1,25 +1,23 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useState, useRef, useEffect } from 'react'; 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'; import { ChevronDownIcon } from '@/components/icons';
export default function LanguageSwitcher() { export default function LanguageSwitcher() {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [availableLanguages, setAvailableLanguages] = useState<LanguageInfo[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => { // Кэш react-query переживает перемонтирование AppShell при смене роута:
const fetchLanguages = async () => { // локальный useState начинал каждый маунт с пустого списка, и до ответа API
try { // компонент рендерил null — переключатель «мигал» при каждой навигации.
const data = await infoApi.getLanguages(); const { data } = useQuery({
setAvailableLanguages(data.languages); queryKey: ['languages'],
} catch { queryFn: infoApi.getLanguages,
// Silently fall back to empty list — component handles it gracefully staleTime: 1000 * 60 * 5,
} });
}; const availableLanguages = data?.languages ?? [];
fetchLanguages();
}, []);
const currentLang = availableLanguages.find((l) => l.code === i18n.language) || const currentLang = availableLanguages.find((l) => l.code === i18n.language) ||
availableLanguages[0] || { code: 'ru', name: 'RU', flag: '🇷🇺' }; 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 { MobileBottomNav } from './MobileBottomNav';
import { AppHeader } from './AppHeader'; import { AppHeader } from './AppHeader';
import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer'; import { useBackgroundConsumer } from '@/components/backgrounds/BackgroundHost';
interface AppShellProps { interface AppShellProps {
children: React.ReactNode; children: React.ReactNode;
@@ -60,6 +60,9 @@ export function AppShell({ children }: AppShellProps) {
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding(); const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags(); const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags();
useScrollRestoration(); useScrollRestoration();
// Анимированный фон рендерит BackgroundHost в App (не перемонтируется при
// смене роута) — здесь только регистрируем, что на этом роуте он нужен.
useBackgroundConsumer();
// Theme toggle visibility // Theme toggle visibility
const { data: enabledThemes } = useQuery({ const { data: enabledThemes } = useQuery({
@@ -180,9 +183,6 @@ export function AppShell({ children }: AppShellProps) {
return ( return (
<div className="min-h-viewport"> <div className="min-h-viewport">
{/* Animated background renders via portal on document.body at z-index: -1 */}
<BackgroundRenderer />
{/* Global components */} {/* Global components */}
<WebSocketNotifications /> <WebSocketNotifications />
<CampaignBonusNotifier /> <CampaignBonusNotifier />
@@ -190,7 +190,11 @@ export function AppShell({ children }: AppShellProps) {
<PromptDialogHost /> <PromptDialogHost />
{/* Desktop Header */} {/* 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 держат {/* 3-зонный grid: лого | капсула | действия. Колонки 1fr_auto_1fr держат
капсулу строго по центру вьюпорта НЕЗАВИСИМО от ширины лого/действий, капсулу строго по центру вьюпорта НЕЗАВИСИМО от ширины лого/действий,
а действия — у правого края. Поэтому ничего не «скачет» при переходах а действия — у правого края. Поэтому ничего не «скачет» при переходах

View File

@@ -235,6 +235,17 @@
scrollbar-color: rgb(var(--color-dark-700)) transparent; 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) */ /* Hide scrollbar on mobile (iOS/Android) */
@media (max-width: 1023px) { @media (max-width: 1023px) {
html, html,