fix(ui): не перезапускать анимированный фон при смене страницы

This commit is contained in:
kewldan
2026-07-13 01:52:36 +03:00
parent a7ff8a6b09
commit 1ea9b99ab5
3 changed files with 52 additions and 4 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

@@ -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 />