diff --git a/src/App.tsx b/src/App.tsx index dfb426e..0a6d145 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( <> + {/* Живёт над : анимация фона не перезапускается при навигации */} + {/* Public routes */} diff --git a/src/components/backgrounds/BackgroundHost.tsx b/src/components/backgrounds/BackgroundHost.tsx new file mode 100644 index 0000000..bc1c4ca --- /dev/null +++ b/src/components/backgrounds/BackgroundHost.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import { create } from 'zustand'; +import { BackgroundRenderer } from './BackgroundRenderer'; + +/** + * Держит анимированный фон смонтированным ПОВЕРХ смены роутов. + * + * Каждый роут оборачивается в собственный , поэтому AppShell + * перемонтируется при каждой навигации — если рендерить BackgroundRenderer + * прямо из AppShell, анимация фона стартует заново на каждой странице. + * + * Вместо этого AppShell лишь регистрирует своё присутствие через + * useBackgroundConsumer(), а BackgroundHost живёт в App (над , не + * перемонтируется) и держит один экземпляр 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 ? : null; +} diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 05d9e3c..18188cb 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -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 (
- {/* Animated background renders via portal on document.body at z-index: -1 */} - - {/* Global components */}