From ddfdb0df0405daf410f87631bd85d91ed4aaf091 Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 26 Sep 2026 11:34:08 +0700 Subject: [PATCH] perf(dashboard): lazy-load charts and marked, preload in background on idle - Drop UsageStats from shared barrel so recharts (~589KB) stays out of the initial bundle of every page; usage page imports it directly - Convert UsageChart/ProviderBarChart/TopModelsChart to next/dynamic - Preload chart chunks via requestIdleCallback in DashboardLayout so navigating to Usage is still instant - Lazy-import marked inside ChangelogModal (only when opened) - Route Sidebar/EndpointPageClient through settingsStore: coalesce concurrent in-flight GET, merge PATCH response over cache (PATCH omits GET-only hasPassword) to kill duplicate /api/settings calls - Delay /api/version npm check 2.5s after first render Co-Authored-By: Claude Code --- .../dashboard/endpoint/EndpointPageClient.js | 32 +++++++---------- src/app/(dashboard)/dashboard/usage/page.js | 3 +- src/shared/components/ChangelogModal.js | 14 ++++---- src/shared/components/Sidebar.js | 21 ++++++----- src/shared/components/UsageStats.js | 8 ++--- src/shared/components/index.js | 1 - .../components/layouts/DashboardLayout.js | 20 ++++++++++- src/store/settingsStore.js | 36 ++++++++++++------- 8 files changed, 80 insertions(+), 55 deletions(-) diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index b6bfd2e4..66dd866b 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -13,6 +13,7 @@ import { CLIENT_PING_FAST_MS, } from "./endpointConstants"; import { clientPingUrl, clientPingAny } from "./endpointPing"; +import useSettingsStore from "@/store/settingsStore"; import EndpointRow from "./components/EndpointRow"; import StatusAlert from "./components/StatusAlert"; import Tooltip from "./components/Tooltip"; @@ -194,16 +195,15 @@ export default function APIPageClient({ machineId }) { const loadSettings = async () => { setTunnelChecking(true); try { - const [settingsRes, statusRes] = await Promise.all([ - fetch("/api/settings"), + const [settingsData, statusRes] = await Promise.all([ + useSettingsStore.getState().fetchSettings(), fetch("/api/tunnel/status", { cache: "no-store" }) ]); - if (settingsRes.ok) { - const data = await settingsRes.json(); - setRequireApiKey(data.requireApiKey || false); - setRequireLogin(data.requireLogin !== false); - setHasPassword(data.hasPassword || false); - setTunnelDashboardAccess(data.tunnelDashboardAccess || false); + if (settingsData) { + setRequireApiKey(settingsData.requireApiKey || false); + setRequireLogin(settingsData.requireLogin !== false); + setHasPassword(settingsData.hasPassword || false); + setTunnelDashboardAccess(settingsData.tunnelDashboardAccess || false); } if (statusRes.ok) { const data = await statusRes.json(); @@ -229,12 +229,8 @@ export default function APIPageClient({ machineId }) { const handleTunnelDashboardAccess = async (value) => { try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tunnelDashboardAccess: value }), - }); - if (res.ok) setTunnelDashboardAccess(value); + const updated = await useSettingsStore.getState().patchSettings({ tunnelDashboardAccess: value }); + if (updated) setTunnelDashboardAccess(value); } catch (error) { console.log("Error updating tunnelDashboardAccess:", error); } @@ -242,12 +238,8 @@ export default function APIPageClient({ machineId }) { const handleRequireApiKey = async (value) => { try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ requireApiKey: value }), - }); - if (res.ok) setRequireApiKey(value); + const updated = await useSettingsStore.getState().patchSettings({ requireApiKey: value }); + if (updated) setRequireApiKey(value); } catch (error) { console.log("Error updating requireApiKey:", error); } diff --git a/src/app/(dashboard)/dashboard/usage/page.js b/src/app/(dashboard)/dashboard/usage/page.js index 2b43198b..3f47b644 100644 --- a/src/app/(dashboard)/dashboard/usage/page.js +++ b/src/app/(dashboard)/dashboard/usage/page.js @@ -2,7 +2,8 @@ import { Suspense, useState } from "react"; import { useSearchParams, useRouter } from "next/navigation"; -import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components"; +import { RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components"; +import UsageStats from "@/shared/components/UsageStats"; import RequestDetailsTab from "./components/RequestDetailsTab"; const PERIODS = [ diff --git a/src/shared/components/ChangelogModal.js b/src/shared/components/ChangelogModal.js index 591f6a9e..abc320a7 100644 --- a/src/shared/components/ChangelogModal.js +++ b/src/shared/components/ChangelogModal.js @@ -3,11 +3,8 @@ import { useEffect, useState, useRef } from "react"; import { createPortal } from "react-dom"; import PropTypes from "prop-types"; -import { marked } from "marked"; import { GITHUB_CONFIG } from "@/shared/constants/config"; -marked.setOptions({ gfm: true, breaks: true }); - export default function ChangelogModal({ isOpen, onClose }) { const [html, setHtml] = useState(""); const [loading, setLoading] = useState(false); @@ -18,12 +15,17 @@ export default function ChangelogModal({ isOpen, onClose }) { if (!isOpen || html) return; setLoading(true); setError(""); - fetch(GITHUB_CONFIG.changelogUrl) - .then((res) => { + Promise.all([ + fetch(GITHUB_CONFIG.changelogUrl).then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); + }), + import("marked"), + ]) + .then(([md, { marked }]) => { + marked.setOptions({ gfm: true, breaks: true }); + setHtml(marked.parse(md)); }) - .then((md) => setHtml(marked.parse(md))) .catch((err) => setError(err.message || "Failed to load")) .finally(() => setLoading(false)); }, [isOpen, html]); diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 716f59fb..b3aa65a7 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -8,6 +8,7 @@ import { cn } from "@/shared/utils/cn"; import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config"; import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import useSettingsStore from "@/store/settingsStore"; import Button from "./Button"; import { ConfirmModal } from "./Modal"; import NineRemotePromoModal from "./NineRemotePromoModal"; @@ -54,18 +55,20 @@ export default function Sidebar({ onClose }) { const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest; useEffect(() => { - fetch("/api/settings") - .then(res => res.json()) - .then(data => { if (data.enableTranslator) setEnableTranslator(true); }) - .catch(() => {}); + useSettingsStore.getState().fetchSettings().then((data) => { + if (data?.enableTranslator) setEnableTranslator(true); + }); }, []); - // Lazy check for new npm version on mount + // Lazy check for new npm version in background after initial render useEffect(() => { - fetch("/api/version") - .then(res => res.json()) - .then(data => { if (data.hasUpdate) setUpdateInfo(data); }) - .catch(() => {}); + const timer = setTimeout(() => { + fetch("/api/version") + .then(res => res.json()) + .then(data => { if (data.hasUpdate) setUpdateInfo(data); }) + .catch(() => {}); + }, 2500); + return () => clearTimeout(timer); }, []); const isActive = (href) => { diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index bcac0bad..c4007812 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -15,11 +15,11 @@ import Card from "./Card"; import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards"; import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/components/UsageTable"; import dynamic from "next/dynamic"; -// Lazy-load: keeps @xyflow/react out of the shared bundle until topology renders +// Lazy-load: keeps @xyflow/react and recharts out of the initial bundle const ProviderTopology = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/ProviderTopology"), { ssr: false }); -import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart"; -import ProviderBarChart from "@/app/(dashboard)/dashboard/usage/components/ProviderBarChart"; -import TopModelsChart from "@/app/(dashboard)/dashboard/usage/components/TopModelsChart"; +const UsageChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/UsageChart"), { ssr: false }); +const ProviderBarChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/ProviderBarChart"), { ssr: false }); +const TopModelsChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/TopModelsChart"), { ssr: false }); function timeAgo(timestamp) { const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000); diff --git a/src/shared/components/index.js b/src/shared/components/index.js index 3d508e91..e5698a63 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -18,7 +18,6 @@ export { default as ModelSelectModal } from "./ModelSelectModal"; export { default as ManualConfigModal } from "./ManualConfigModal"; export { default as ComboFormModal } from "./ComboFormModal"; export { default as McpMarketplaceModal } from "./McpMarketplaceModal"; -export { default as UsageStats } from "./UsageStats"; export { default as LanguageSwitcher } from "./LanguageSwitcher"; export { default as NineRemoteButton } from "./NineRemoteButton"; export { default as HeaderMenu } from "./HeaderMenu"; diff --git a/src/shared/components/layouts/DashboardLayout.js b/src/shared/components/layouts/DashboardLayout.js index aa555bd5..0f175f95 100644 --- a/src/shared/components/layouts/DashboardLayout.js +++ b/src/shared/components/layouts/DashboardLayout.js @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { usePathname } from "next/navigation"; import { useNotificationStore } from "@/store/notificationStore"; import Sidebar from "../Sidebar"; @@ -37,6 +37,24 @@ export default function DashboardLayout({ children }) { const notifications = useNotificationStore((state) => state.notifications); const removeNotification = useNotificationStore((state) => state.removeNotification); + // Preload heavy usage charts in background when browser is idle + useEffect(() => { + const preload = () => { + import("@/shared/components/UsageStats").catch(() => {}); + import("@/app/(dashboard)/dashboard/usage/components/UsageChart").catch(() => {}); + import("@/app/(dashboard)/dashboard/usage/components/ProviderBarChart").catch(() => {}); + import("@/app/(dashboard)/dashboard/usage/components/TopModelsChart").catch(() => {}); + }; + if (typeof window !== "undefined") { + if ("requestIdleCallback" in window) { + const id = window.requestIdleCallback(preload, { timeout: 4000 }); + return () => window.cancelIdleCallback(id); + } + const timer = setTimeout(preload, 2500); + return () => clearTimeout(timer); + } + }, []); + return (
diff --git a/src/store/settingsStore.js b/src/store/settingsStore.js index e5f0a713..563d2e39 100644 --- a/src/store/settingsStore.js +++ b/src/store/settingsStore.js @@ -3,6 +3,8 @@ import { create } from "zustand"; import { CLIENT_STORE_TTL_MS } from "@/shared/constants/config"; +let inFlightSettingsPromise = null; + const useSettingsStore = create((set, get) => ({ settings: null, loading: false, @@ -11,23 +13,30 @@ const useSettingsStore = create((set, get) => ({ invalidate: () => set({ lastFetched: 0 }), - // Skips network when cache is fresh; pass {force:true} to override + // Skips network when cache is fresh; coalesce concurrent in-flight requests fetchSettings: async ({ force = false } = {}) => { const { lastFetched, settings } = get(); if (!force && settings && Date.now() - lastFetched < CLIENT_STORE_TTL_MS) return settings; + if (inFlightSettingsPromise) return inFlightSettingsPromise; + set({ loading: true, error: null }); - try { - const res = await fetch("/api/settings"); - const data = await res.json(); - if (res.ok) { - set({ settings: data, loading: false, lastFetched: Date.now() }); - return data; + inFlightSettingsPromise = (async () => { + try { + const res = await fetch("/api/settings"); + const data = await res.json(); + if (res.ok) { + set({ settings: data, loading: false, lastFetched: Date.now() }); + return data; + } + set({ error: data.error, loading: false }); + } catch (e) { + set({ error: "Failed to fetch settings", loading: false }); + } finally { + inFlightSettingsPromise = null; } - set({ error: data.error, loading: false }); - } catch (e) { - set({ error: "Failed to fetch settings", loading: false }); - } - return null; + return null; + })(); + return inFlightSettingsPromise; }, // PATCH server + merge into local cache (no extra fetch needed) @@ -40,7 +49,8 @@ const useSettingsStore = create((set, get) => ({ }); if (!res.ok) return null; const updated = await res.json(); - set({ settings: updated, lastFetched: Date.now() }); + // Merge, not replace: PATCH response omits GET-only fields (e.g. hasPassword) + set({ settings: { ...get().settings, ...updated }, lastFetched: Date.now() }); return updated; } catch { return null;