refactor: remove framer-motion dependencies and implement custom active indicator for improved performance and animations

This commit is contained in:
hamster1963
2026-06-10 13:23:10 +08:00
parent 8e0f7a2fb6
commit eb682dc75f
13 changed files with 359 additions and 217 deletions
+36 -20
View File
@@ -1,5 +1,5 @@
import { m } from "framer-motion";
import { createRef, useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { cn } from "@/lib/utils";
export default function GroupSwitch({
@@ -17,7 +17,13 @@ export default function GroupSwitch({
: undefined;
const scrollRef = useRef<HTMLDivElement>(null);
const tagRefs = useRef(tabs.map(() => createRef<HTMLDivElement>()));
const {
containerRef,
enableIndicatorAnimation,
indicator,
itemRefs,
setItemRef,
} = useActiveIndicator(tabs, currentTab);
useEffect(() => {
const container = scrollRef.current;
@@ -50,16 +56,16 @@ export default function GroupSwitch({
}, [tabs, setCurrentTab]);
useEffect(() => {
const currentTagRef = tagRefs.current[tabs.indexOf(currentTab)];
const currentTagRef = itemRefs.current[tabs.indexOf(currentTab)];
if (currentTagRef?.current) {
currentTagRef.current.scrollIntoView({
if (currentTagRef) {
currentTagRef.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}
}, [currentTab, tabs.indexOf]);
}, [currentTab, itemRefs, tabs]);
if (tabs.length === 1 && tabs[0] === "All") {
return null;
@@ -71,18 +77,38 @@ export default function GroupSwitch({
className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]"
>
<div
ref={containerRef}
className={cn(
"flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800",
"relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800",
{
"bg-stone-100/70 dark:bg-stone-800/70": customBackgroundImage,
},
)}
>
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 46,
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{tabs.map((tab: string, index: number) => (
<div
key={tab}
ref={tagRefs.current[index]}
onClick={() => setCurrentTab(tab)}
ref={setItemRef(index)}
onClick={() => {
if (currentTab !== tab) {
enableIndicatorAnimation();
}
setCurrentTab(tab);
}}
className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-2 text-[13px] font-semibold transition-all duration-500 ease-in-out hover:text-stone-950 hover:dark:text-stone-50",
currentTab === tab
@@ -90,16 +116,6 @@ export default function GroupSwitch({
: "text-stone-400 dark:text-stone-500",
)}
>
{currentTab === tab && (
<m.div
layoutId="tab-switch"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
originY: "0px",
borderRadius: 46,
}}
/>
)}
<div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{tab}</p>
</div>
+6 -15
View File
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { AnimatePresence, m } from "framer-motion";
import { ImageMinus } from "lucide-react";
import { DateTime } from "luxon";
import { useEffect, useRef, useState } from "react";
@@ -252,20 +251,12 @@ export function RefreshToast() {
}
return (
<AnimatePresence>
<m.div
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.8 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.8 }}
transition={{ type: "spring", duration: 0.8 }}
className="fixed left-1/2 -translate-x-1/2 top-8 z-999 flex items-center justify-between gap-4 rounded-[50px] border border-solid bg-white px-2 py-1.5 shadow-xl shadow-black/5 dark:border-stone-700 dark:bg-stone-800 dark:shadow-none"
>
<section className="flex items-center gap-1.5">
<LoadingSpinner />
<p className="text-[12.5px] font-medium">{t("refreshing")}...</p>
</section>
</m.div>
</AnimatePresence>
<div className="refresh-toast-animate fixed left-1/2 -translate-x-1/2 top-8 z-999 flex items-center justify-between gap-4 rounded-[50px] border border-solid bg-white px-2 py-1.5 shadow-xl shadow-black/5 dark:border-stone-700 dark:bg-stone-800 dark:shadow-none">
<section className="flex items-center gap-1.5">
<LoadingSpinner />
<p className="text-[12.5px] font-medium">{t("refreshing")}...</p>
</section>
</div>
);
}
+44 -50
View File
@@ -1,4 +1,3 @@
import { AnimatePresence, m } from "framer-motion";
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -12,58 +11,53 @@ const MapTooltip = memo(function MapTooltip() {
if (!tooltipData) return null;
return (
<AnimatePresence mode="wait">
<m.div
initial={{ opacity: 0, filter: "blur(10px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(10px)" }}
className="absolute hidden lg:block bg-white dark:bg-neutral-800 px-2 py-1 rounded shadow-lg text-sm dark:border dark:border-neutral-700 z-50"
key={tooltipData.country}
<div
className="tooltip-animate absolute hidden lg:block bg-white dark:bg-neutral-800 px-2 py-1 rounded shadow-lg text-sm dark:border dark:border-neutral-700 z-50"
key={tooltipData.country}
style={{
left: tooltipData.centroid[0],
top: tooltipData.centroid[1],
transform: "translate(20%, -50%)",
}}
onMouseEnter={(e) => {
e.stopPropagation();
}}
>
<div>
<p className="font-medium">
{tooltipData.country === "China"
? "Mainland China"
: tooltipData.country}
</p>
<p className="text-neutral-600 dark:text-neutral-400 text-xs font-light mb-1">
{tooltipData.count} {t("map.Servers")}
</p>
</div>
<div
className="border-t dark:border-neutral-700 pt-1"
style={{
left: tooltipData.centroid[0],
top: tooltipData.centroid[1],
transform: "translate(20%, -50%)",
}}
onMouseEnter={(e) => {
e.stopPropagation();
maxHeight: "200px",
overflowY: "auto",
}}
>
<div>
<p className="font-medium">
{tooltipData.country === "China"
? "Mainland China"
: tooltipData.country}
</p>
<p className="text-neutral-600 dark:text-neutral-400 text-xs font-light mb-1">
{tooltipData.count} {t("map.Servers")}
</p>
</div>
<div
className="border-t dark:border-neutral-700 pt-1"
style={{
maxHeight: "200px",
overflowY: "auto",
}}
>
{tooltipData.servers.map((server) => (
<button
key={server.id}
type="button"
className="flex items-center gap-1.5 py-0.5 text-neutral-500 transition-colors hover:text-black dark:text-neutral-400 dark:hover:text-white"
onClick={() => {
sessionStorage.setItem("fromMainPage", "true");
navigate(`/server/${server.id}`);
}}
>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${server.status ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="text-xs">{server.name}</span>
</button>
))}
</div>
</m.div>
</AnimatePresence>
{tooltipData.servers.map((server) => (
<button
key={server.id}
type="button"
className="flex items-center gap-1.5 py-0.5 text-neutral-500 transition-colors hover:text-black dark:text-neutral-400 dark:hover:text-white"
onClick={() => {
sessionStorage.setItem("fromMainPage", "true");
navigate(`/server/${server.id}`);
}}
>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${server.status ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="text-xs">{server.name}</span>
</button>
))}
</div>
</div>
);
});
+37 -19
View File
@@ -1,7 +1,6 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { m } from "framer-motion";
import * as React from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
@@ -34,6 +33,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import {
fetchLoginUser,
fetchMonitor,
@@ -250,11 +250,20 @@ export const NetworkChartClient = React.memo(function NetworkChart({
const [showPeriodLoading, setShowPeriodLoading] = React.useState(false);
const loadingStartedAtRef = React.useRef<number | null>(null);
const TIME_RANGE_OPTIONS: { value: MonitorPeriod; label: string }[] = [
{ value: "1d", label: t("monitor.period1d") },
{ value: "7d", label: t("monitor.period7d") },
{ value: "30d", label: t("monitor.period30d") },
];
const TIME_RANGE_OPTIONS = useMemo<{ value: MonitorPeriod; label: string }[]>(
() => [
{ value: "1d", label: t("monitor.period1d") },
{ value: "7d", label: t("monitor.period7d") },
{ value: "30d", label: t("monitor.period30d") },
],
[t],
);
const timeRangeValues = useMemo(
() => TIME_RANGE_OPTIONS.map((option) => option.value),
[TIME_RANGE_OPTIONS],
);
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(timeRangeValues, period);
React.useEffect(() => {
let timeoutId: number | undefined;
@@ -581,13 +590,33 @@ export const NetworkChartClient = React.memo(function NetworkChart({
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3 sm:-mt-5 -mt-3 flex-wrap">
<TooltipProvider delayDuration={120}>
<div className="flex items-center gap-1 rounded-full bg-muted dark:bg-muted/40 p-0.5 border border-border/60 dark:border-border">
{TIME_RANGE_OPTIONS.map((option) => {
<div
ref={containerRef}
className="relative flex items-center gap-1 rounded-full bg-muted dark:bg-muted/40 p-0.5 border border-border/60 dark:border-border"
>
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
style={{
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{TIME_RANGE_OPTIONS.map((option, index) => {
const isLocked = !isLogin && option.value !== "1d";
const optionItem = (
<div
ref={setItemRef(index)}
onClick={() => {
if (!isLocked) {
if (period !== option.value) {
enableIndicatorAnimation();
}
onPeriodChange(option.value);
}
}}
@@ -599,17 +628,6 @@ export const NetworkChartClient = React.memo(function NetworkChart({
isLocked && "cursor-not-allowed opacity-40 grayscale",
)}
>
{period === option.value && (
<m.div
layoutId="network-period-selector-active"
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<span className="relative z-20">{option.label}</span>
</div>
);
+38 -16
View File
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { m } from "framer-motion";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -24,6 +23,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { formatBytes } from "@/lib/format";
import {
@@ -106,17 +106,42 @@ function PeriodSelector({
}) {
const { t } = useTranslation();
const periods: { value: ChartPeriod; label: string }[] = [
{ value: "realtime", label: t("serverDetailChart.realtime") },
{ value: "1d", label: t("serverDetailChart.period1d") },
{ value: "7d", label: t("serverDetailChart.period7d") },
{ value: "30d", label: t("serverDetailChart.period30d") },
];
const periods = useMemo<{ value: ChartPeriod; label: string }[]>(
() => [
{ value: "realtime", label: t("serverDetailChart.realtime") },
{ value: "1d", label: t("serverDetailChart.period1d") },
{ value: "7d", label: t("serverDetailChart.period7d") },
{ value: "30d", label: t("serverDetailChart.period30d") },
],
[t],
);
const periodValues = useMemo(
() => periods.map((period) => period.value),
[periods],
);
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(periodValues, selectedPeriod);
return (
<TooltipProvider delayDuration={120}>
<div className="flex gap-0.5 mb-3 flex-wrap sm:-mt-5 -mt-3 p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border">
{periods.map((period) => {
<div
ref={containerRef}
className="relative flex gap-0.5 mb-3 flex-wrap sm:-mt-5 -mt-3 p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border"
>
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
style={{
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{periods.map((period, index) => {
const isHistoryPeriod = period.value !== "realtime";
const isLockedByTsdb = !isTsdbEnabled && isHistoryPeriod;
// Only realtime and 1d are available for non-logged-in users
@@ -129,8 +154,12 @@ function PeriodSelector({
const periodItem = (
<div
ref={setItemRef(index)}
onClick={() => {
if (!isLocked) {
if (selectedPeriod !== period.value) {
enableIndicatorAnimation();
}
onPeriodChange(period.value);
}
}}
@@ -142,13 +171,6 @@ function PeriodSelector({
isLocked && "cursor-not-allowed opacity-40 grayscale",
)}
>
{selectedPeriod === period.value && (
<m.div
layoutId="period-selector-active"
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
transition={{ type: "spring", stiffness: 250, damping: 30 }}
/>
)}
<div className="relative z-20 flex items-center gap-1.5">
{period.value === "realtime" && (
<span className="inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500 dark:bg-emerald-400"></span>
+27 -14
View File
@@ -1,5 +1,5 @@
import { m } from "framer-motion";
import { useTranslation } from "react-i18next";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { cn } from "@/lib/utils";
export default function TabSwitch({
@@ -12,6 +12,8 @@ export default function TabSwitch({
setCurrentTab: (tab: string) => void;
}) {
const { t } = useTranslation();
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(tabs, currentTab);
const customBackgroundImage =
(window.CustomBackgroundImage as string) !== ""
? window.CustomBackgroundImage
@@ -19,17 +21,38 @@ export default function TabSwitch({
return (
<div className="z-50 flex flex-col items-start rounded-[50px] server-info-tab">
<div
ref={containerRef}
className={cn(
"flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800",
"relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800",
{
"bg-stone-100/70 dark:bg-stone-800/70": customBackgroundImage,
},
)}
>
{tabs.map((tab: string) => (
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 46,
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{tabs.map((tab: string, index: number) => (
<div
key={tab}
onClick={() => setCurrentTab(tab)}
ref={setItemRef(index)}
onClick={() => {
if (currentTab !== tab) {
enableIndicatorAnimation();
}
setCurrentTab(tab);
}}
className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-2 text-[13px] font-semibold transition-all duration-500 ease-in-out hover:text-stone-950 hover:dark:text-stone-50",
currentTab === tab
@@ -37,16 +60,6 @@ export default function TabSwitch({
: "text-stone-400 dark:text-stone-500",
)}
>
{currentTab === tab && (
<m.div
layoutId="tab-switch-active"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
originY: "0px",
borderRadius: 46,
}}
/>
)}
<div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{t(`tabSwitch.${tab}`)}</p>
</div>
@@ -1 +0,0 @@
export { domMax as default } from "framer-motion";
-12
View File
@@ -1,12 +0,0 @@
import { LazyMotion } from "framer-motion";
const loadFeatures = () =>
import("./framer-lazy-feature").then((res) => res.default);
export const MotionProvider = ({ children }: { children: React.ReactNode }) => {
return (
<LazyMotion features={loadFeatures} strict key="framer">
{children}
</LazyMotion>
);
};
+96
View File
@@ -0,0 +1,96 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
type ActiveIndicator = {
x: number;
y: number;
width: number;
height: number;
shouldAnimate: boolean;
};
export function useActiveIndicator<T>(items: T[], activeItem: T) {
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const hasMeasuredRef = useRef(false);
const shouldAnimateNextRef = useRef(false);
const [indicator, setIndicator] = useState<ActiveIndicator | null>(null);
const enableIndicatorAnimation = useCallback(() => {
shouldAnimateNextRef.current = true;
}, []);
const setItemRef = useCallback(
(index: number) => (node: HTMLDivElement | null) => {
itemRefs.current[index] = node;
},
[],
);
const updateIndicator = useCallback(() => {
const activeIndex = items.indexOf(activeItem);
const activeElement = itemRefs.current[activeIndex];
if (!activeElement || !containerRef.current) {
hasMeasuredRef.current = false;
shouldAnimateNextRef.current = false;
setIndicator((currentIndicator) =>
currentIndicator === null ? currentIndicator : null,
);
return;
}
const nextIndicator = {
x: activeElement.offsetLeft,
y: activeElement.offsetTop,
width: activeElement.offsetWidth,
height: activeElement.offsetHeight,
shouldAnimate: hasMeasuredRef.current && shouldAnimateNextRef.current,
};
hasMeasuredRef.current = true;
shouldAnimateNextRef.current = false;
setIndicator((currentIndicator) => {
if (
currentIndicator &&
currentIndicator.x === nextIndicator.x &&
currentIndicator.y === nextIndicator.y &&
currentIndicator.width === nextIndicator.width &&
currentIndicator.height === nextIndicator.height
) {
return currentIndicator;
}
return nextIndicator;
});
}, [activeItem, items]);
useLayoutEffect(() => {
updateIndicator();
const container = containerRef.current;
if (!container || typeof ResizeObserver === "undefined") {
return;
}
const resizeObserver = new ResizeObserver(updateIndicator);
resizeObserver.observe(container);
for (const item of itemRefs.current) {
if (item) {
resizeObserver.observe(item);
}
}
return () => {
resizeObserver.disconnect();
};
}, [updateIndicator]);
return {
containerRef,
enableIndicatorAnimation,
indicator,
itemRefs,
setItemRef,
};
}
+47
View File
@@ -334,6 +334,53 @@
display: none; /* Chrome, Safari 和 Opera */
}
.tooltip-animate {
opacity: 0;
filter: blur(5px);
animation: tooltip-fade-in 0.1s ease-in-out forwards;
}
.refresh-toast-animate {
animation: refresh-toast-in 0.5s var(--timing) forwards;
}
.active-indicator-fade-in {
animation: active-indicator-fade-in 0.18s var(--timing) forwards;
}
@keyframes tooltip-fade-in {
from {
opacity: 0;
filter: blur(5px);
}
to {
opacity: 1;
filter: blur(0px);
}
}
@keyframes active-indicator-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes refresh-toast-in {
from {
opacity: 0;
filter: blur(10px);
transform: translateX(-50%) scale(0.8);
}
to {
opacity: 1;
filter: blur(0px);
transform: translateX(-50%) scale(1);
}
}
/* Thanks to next.js. */
[data-issues-count-animation] {
display: flex;
+28 -31
View File
@@ -4,7 +4,6 @@ import ReactDOM from "react-dom/client";
import { Toaster } from "sonner";
import App from "./App";
import { MotionProvider } from "./components/motion/motion-provider";
import { ThemeColorManager } from "./components/ThemeColorManager";
import { ThemeProvider } from "./components/ThemeProvider";
import { CommandProvider } from "./context/command-provider";
@@ -23,34 +22,32 @@ if (!rootElement) {
}
ReactDOM.createRoot(rootElement).render(
<MotionProvider>
<ThemeProvider storageKey="vite-ui-theme">
<ThemeColorManager />
<QueryClientProvider client={queryClient}>
<WebSocketProvider url="/api/v1/ws/server">
<CommandProvider>
<StatusProvider>
<SortProvider>
<TooltipProvider>
<App />
<Toaster
duration={1000}
toastOptions={{
classNames: {
default:
"w-fit rounded-full px-2.5 py-1.5 bg-neutral-100 border border-neutral-200 backdrop-blur-xl shadow-none",
},
}}
position="top-center"
className={"flex items-center justify-center"}
/>
<ReactQueryDevtools />
</TooltipProvider>
</SortProvider>
</StatusProvider>
</CommandProvider>
</WebSocketProvider>
</QueryClientProvider>
</ThemeProvider>
</MotionProvider>,
<ThemeProvider storageKey="vite-ui-theme">
<ThemeColorManager />
<QueryClientProvider client={queryClient}>
<WebSocketProvider url="/api/v1/ws/server">
<CommandProvider>
<StatusProvider>
<SortProvider>
<TooltipProvider>
<App />
<Toaster
duration={1000}
toastOptions={{
classNames: {
default:
"w-fit rounded-full px-2.5 py-1.5 bg-neutral-100 border border-neutral-200 backdrop-blur-xl shadow-none",
},
}}
position="top-center"
className={"flex items-center justify-center"}
/>
<ReactQueryDevtools />
</TooltipProvider>
</SortProvider>
</StatusProvider>
</CommandProvider>
</WebSocketProvider>
</QueryClientProvider>
</ThemeProvider>,
);