From 553390af8ec02d7f3aa5d64916ed6e72eb39ccc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 06:29:19 +0000 Subject: [PATCH 1/4] perf: fix react best-practice violations Weekly React/Next.js best-practices audit across apps/ui, apps/code, apps/playground and apps/docs: dedupe per-request server fetches with React.cache, code-split recharts/shiki-heavy client components, defer Stripe.js, parallelize MCP connections, and fix render-phase side effects and effect dependency churn. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019erQdRFifT3KjwKDZkWpGr --- .../src/app/dashboard/(main)/billing/page.tsx | 5 +- apps/code/src/app/dashboard/(main)/layout.tsx | 9 +-- .../src/app/dashboard/(main)/profile/page.tsx | 6 +- .../code/src/app/dashboard/DashboardShell.tsx | 6 +- .../agents/[agentId]/AgentDetailClient.tsx | 12 ++- .../dashboard/components/UsageOverview.tsx | 10 ++- .../src/components/ui/flickering-grid.tsx | 20 +++-- apps/code/src/lib/public-profile.ts | 9 ++- apps/code/src/lib/server-api.ts | 14 ++++ apps/code/src/lib/stripe.ts | 9 ++- apps/docs/next.config.ts | 5 ++ apps/playground/src/app/api/chat/route.ts | 78 +++++++++++-------- .../src/app/share/[shareId]/page.tsx | 37 +++++---- .../src/components/ai-elements/shimmer.tsx | 34 ++++---- .../playground/chat-page-client.tsx | 3 +- apps/playground/src/lib/escape-run.ts | 25 +++--- apps/ui/src/app/dashboard/[orgId]/layout.tsx | 48 ++---------- apps/ui/src/app/features/[slug]/page.tsx | 51 ++++++++---- apps/ui/src/app/models/[name]/uptime/page.tsx | 10 ++- apps/ui/src/app/rankings/page.tsx | 15 +++- .../src/components/landing/code-example.tsx | 13 ++-- apps/ui/src/hooks/useUser.ts | 30 +++---- apps/ui/src/lib/announcements.ts | 43 ++++++++++ apps/ui/src/lib/getUser.ts | 6 +- apps/ui/src/lib/server-api.ts | 8 +- 25 files changed, 318 insertions(+), 188 deletions(-) create mode 100644 apps/ui/src/lib/announcements.ts diff --git a/apps/code/src/app/dashboard/(main)/billing/page.tsx b/apps/code/src/app/dashboard/(main)/billing/page.tsx index cea9fe8c87..bdc619d3b5 100644 --- a/apps/code/src/app/dashboard/(main)/billing/page.tsx +++ b/apps/code/src/app/dashboard/(main)/billing/page.tsx @@ -1,8 +1,7 @@ -import { fetchServerData } from "@/lib/server-api"; +import { fetchServerData, getDevPlanStatus } from "@/lib/server-api"; import BillingClient from "./BillingClient"; -import type { DevPlanStatus } from "@/app/dashboard/useDevPlanStatus"; import type { paths } from "@/lib/api/v1"; type PaymentMethod = @@ -10,7 +9,7 @@ type PaymentMethod = export default async function BillingPage() { const [devPlanStatus, paymentMethod] = await Promise.all([ - fetchServerData("GET", "/dev-plans/status"), + getDevPlanStatus(), fetchServerData("GET", "/dev-plans/payment-method"), ]); diff --git a/apps/code/src/app/dashboard/(main)/layout.tsx b/apps/code/src/app/dashboard/(main)/layout.tsx index 4dced84bf3..b726a9f5ec 100644 --- a/apps/code/src/app/dashboard/(main)/layout.tsx +++ b/apps/code/src/app/dashboard/(main)/layout.tsx @@ -1,10 +1,7 @@ import { redirect } from "next/navigation"; import DashboardShell from "@/app/dashboard/DashboardShell"; -import { fetchServerData } from "@/lib/server-api"; - -import type { DevPlanStatus } from "@/app/dashboard/useDevPlanStatus"; -import type { UserMe } from "@/hooks/useUser"; +import { getDevPlanStatus, getUserMe } from "@/lib/server-api"; export default async function DashboardLayout({ children, @@ -12,8 +9,8 @@ export default async function DashboardLayout({ children: React.ReactNode; }) { const [userData, devPlanStatus] = await Promise.all([ - fetchServerData("GET", "/user/me"), - fetchServerData("GET", "/dev-plans/status"), + getUserMe(), + getDevPlanStatus(), ]); if (!userData?.user) { diff --git a/apps/code/src/app/dashboard/(main)/profile/page.tsx b/apps/code/src/app/dashboard/(main)/profile/page.tsx index 5ce897ea37..eec1f7a3e9 100644 --- a/apps/code/src/app/dashboard/(main)/profile/page.tsx +++ b/apps/code/src/app/dashboard/(main)/profile/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; -import { fetchServerData } from "@/lib/server-api"; +import { fetchServerData, getUserMe } from "@/lib/server-api"; import { ProfilePageClient } from "./ProfilePageClient"; @@ -9,8 +9,6 @@ import type { Metadata } from "next"; type ProfileResponse = paths["/user/profile"]["get"]["responses"][200]["content"]["application/json"]; -type UserMeResponse = - paths["/user/me"]["get"]["responses"][200]["content"]["application/json"]; export const metadata: Metadata = { title: "Your Profile · DevPass", @@ -20,7 +18,7 @@ export const metadata: Metadata = { export default async function ProfilePage() { const [profileData, userData] = await Promise.all([ fetchServerData("GET", "/user/profile"), - fetchServerData("GET", "/user/me"), + getUserMe(), ]); if (!userData?.user) { diff --git a/apps/code/src/app/dashboard/DashboardShell.tsx b/apps/code/src/app/dashboard/DashboardShell.tsx index c293e663d3..b6b0bd6024 100644 --- a/apps/code/src/app/dashboard/DashboardShell.tsx +++ b/apps/code/src/app/dashboard/DashboardShell.tsx @@ -176,7 +176,6 @@ export default function DashboardShell({ const { posthogKey, googleAdsPurchaseConversion, googleAdsSignupConversion } = config; const api = useApi(); - const { stripe, isLoading: stripeLoading } = useStripe(); const queryClient = useQueryClient(); const { user } = useUser({ @@ -191,6 +190,11 @@ export default function DashboardShell({ const subscribeMutation = api.useMutation("post", "/dev-plans/subscribe"); const finalizeMutation = api.useMutation("post", "/dev-plans/finalize"); const setupSessionId = searchParams.get("setup_session_id"); + // Stripe.js is only needed to finalize a checkout setup session, so skip + // loading it on every other dashboard view. + const { stripe, isLoading: stripeLoading } = useStripe( + Boolean(setupSessionId), + ); const signupMethod = searchParams.get("signup_method"); const signupTracked = useRef(false); diff --git a/apps/code/src/app/dashboard/agents/[agentId]/AgentDetailClient.tsx b/apps/code/src/app/dashboard/agents/[agentId]/AgentDetailClient.tsx index a598a466f2..b755f8cb5a 100644 --- a/apps/code/src/app/dashboard/agents/[agentId]/AgentDetailClient.tsx +++ b/apps/code/src/app/dashboard/agents/[agentId]/AgentDetailClient.tsx @@ -12,12 +12,12 @@ import { Terminal, Zap, } from "lucide-react"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { notFound, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { AgentModelUsageChart } from "@/app/dashboard/components/AgentModelUsageChart"; import { type AgentDefinition, AGENTS, @@ -31,6 +31,16 @@ import { useApi, useFetchClient } from "@/lib/fetch-client"; import { buildAgentLogsCsv } from "@llmgateway/shared"; +// The model usage chart pulls in recharts and renders below the fold, so +// keep it out of the agent detail page's initial bundle. +const AgentModelUsageChart = dynamic( + () => + import("@/app/dashboard/components/AgentModelUsageChart").then( + (mod) => mod.AgentModelUsageChart, + ), + { ssr: false, loading: () =>
}, +); + type ModelSortColumn = | "id" | "provider" diff --git a/apps/code/src/app/dashboard/components/UsageOverview.tsx b/apps/code/src/app/dashboard/components/UsageOverview.tsx index 6e022a34bf..b086fa1855 100644 --- a/apps/code/src/app/dashboard/components/UsageOverview.tsx +++ b/apps/code/src/app/dashboard/components/UsageOverview.tsx @@ -2,13 +2,13 @@ import { format, formatDistanceToNowStrict } from "date-fns"; import { Activity, Coins, Cpu, Gem, TrendingUp } from "lucide-react"; +import dynamic from "next/dynamic"; import { usePostHog } from "posthog-js/react"; import { useEffect } from "react"; import { useAppConfig } from "@/lib/config"; import { useApi } from "@/lib/fetch-client"; -import { AgentModelUsageChart } from "./AgentModelUsageChart"; import AllowanceExhaustedCard from "./AllowanceExhaustedCard"; import PayAsYouGoCard from "./PayAsYouGoCard"; import ResetPassCard from "./ResetPassCard"; @@ -17,6 +17,14 @@ import { UsageBar } from "./UsageBar"; import type { paths } from "@/lib/api/v1"; import type { DevPlanCycle } from "@llmgateway/shared"; +// The model usage chart pulls in recharts and renders below the fold, so +// keep it out of the usage page's initial bundle. +const AgentModelUsageChart = dynamic( + () => + import("./AgentModelUsageChart").then((mod) => mod.AgentModelUsageChart), + { ssr: false, loading: () =>
}, +); + type ActivityResponse = paths["/activity"]["get"]["responses"][200]["content"]["application/json"]; type ActivityItem = ActivityResponse["activity"][number]; diff --git a/apps/code/src/components/ui/flickering-grid.tsx b/apps/code/src/components/ui/flickering-grid.tsx index 14288469c3..22f0083736 100644 --- a/apps/code/src/components/ui/flickering-grid.tsx +++ b/apps/code/src/components/ui/flickering-grid.tsx @@ -36,7 +36,10 @@ export const FlickeringGrid: React.FC = ({ }) => { const canvasRef = useRef(null); const containerRef = useRef(null); - const [isInView, setIsInView] = useState(false); + // Ref instead of state: visibility only gates the animation loop, and + // keeping it out of the effect's dependencies stops every viewport + // crossing from tearing down and rebuilding the observers and buffers. + const isInViewRef = useRef(false); const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const memoizedColor = useMemo(() => { @@ -149,7 +152,7 @@ export const FlickeringGrid: React.FC = ({ let lastTime = 0; const animate = (time: number) => { - if (!isInView || !gridParams) { + if (!isInViewRef.current || !gridParams) { return; } @@ -176,15 +179,16 @@ export const FlickeringGrid: React.FC = ({ intersectionObserver = new IntersectionObserver( ([entry]) => { - setIsInView(entry.isIntersecting); + const wasInView = isInViewRef.current; + isInViewRef.current = entry.isIntersecting; + if (entry.isIntersecting && !wasInView) { + lastTime = performance.now(); + animationFrameId = requestAnimationFrame(animate); + } }, { threshold: 0 }, ); intersectionObserver.observe(canvas); - - if (isInView) { - animationFrameId = requestAnimationFrame(animate); - } } return () => { @@ -198,7 +202,7 @@ export const FlickeringGrid: React.FC = ({ intersectionObserver.disconnect(); } }; - }, [setupCanvas, updateSquares, drawGrid, width, height, isInView]); + }, [setupCanvas, updateSquares, drawGrid, width, height]); return (
{ const config = getConfig(); @@ -24,4 +27,4 @@ export async function fetchPublicProfile( } catch { return null; } -} +}); diff --git a/apps/code/src/lib/server-api.ts b/apps/code/src/lib/server-api.ts index 104989be78..dac7165645 100644 --- a/apps/code/src/lib/server-api.ts +++ b/apps/code/src/lib/server-api.ts @@ -1,9 +1,12 @@ import { cookies } from "next/headers"; import createFetchClient from "openapi-fetch"; +import { cache } from "react"; import { getConfig } from "./config-server"; import type { paths } from "./api/v1"; +import type { DevPlanStatus } from "@/app/dashboard/useDevPlanStatus"; +import type { UserMe } from "@/hooks/useUser"; // Typed client for public, unauthenticated endpoints. Unlike // createServerApiClient below it never reads the request cookies, so pages @@ -54,6 +57,17 @@ type PatchPaths = { [P in keyof paths]: paths[P] extends { patch: any } ? P : never; }[keyof paths]; +// The dashboard layout and several pages request these endpoints +// independently in the same render pass; cache() collapses the duplicate +// round-trips into one per request. +export const getUserMe = cache(() => + fetchServerData("GET", "/user/me"), +); + +export const getDevPlanStatus = cache(() => + fetchServerData("GET", "/dev-plans/status"), +); + export async function fetchServerData( method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: keyof paths, diff --git a/apps/code/src/lib/stripe.ts b/apps/code/src/lib/stripe.ts index caa1dd771e..132f6aa2b0 100644 --- a/apps/code/src/lib/stripe.ts +++ b/apps/code/src/lib/stripe.ts @@ -19,13 +19,18 @@ function getStripePromise(publishableKey: string) { return stripePromise; } -export function useStripe() { +// Pass enabled: false to skip loading Stripe.js entirely until the caller +// actually needs it — the script is ~200KB and phones home on load. +export function useStripe(enabled = true) { const { stripePublishableKey } = useAppConfig(); const [stripe, setStripe] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { + if (!enabled) { + return; + } getStripePromise(stripePublishableKey ?? FALLBACK_TEST_PUBLISHABLE_KEY) .then((stripeInstance) => { setStripe(stripeInstance); @@ -35,7 +40,7 @@ export function useStripe() { setError(err); setIsLoading(false); }); - }, [stripePublishableKey]); + }, [enabled, stripePublishableKey]); return { stripe, isLoading, error }; } diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index b85d293037..20af734d54 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -14,6 +14,11 @@ const nextConfig: NextConfig = { productionBrowserSourceMaps: true, reactCompiler: true, transpilePackages: ["shiki"], + // lib/source.ts imports the full lucide-react icon barrel; transform it + // to direct imports so the whole icon set stays out of the bundles. + experimental: { + optimizePackageImports: ["lucide-react"], + }, rewrites() { return [ diff --git a/apps/playground/src/app/api/chat/route.ts b/apps/playground/src/app/api/chat/route.ts index e1356db735..a0c7c58356 100644 --- a/apps/playground/src/app/api/chat/route.ts +++ b/apps/playground/src/app/api/chat/route.ts @@ -1004,42 +1004,56 @@ export async function POST(req: Request) { mcp_servers?.filter((server) => server.enabled) ?? []; try { - // Create MCP clients for each enabled server (with timeout) - for (const server of enabledMcpServers) { - try { - // SSRF Protection: Validate URL before creating transport - const urlValidation = validateMcpServerUrl(server.url); - if (!urlValidation.valid) { - continue; // Skip this server - } + // Create MCP clients for all enabled servers in parallel (each with its + // own timeout) — the connections are independent, so connecting serially + // would stack the per-server latency ahead of the first token. + const connectionResults = await Promise.all( + enabledMcpServers.map( + async (server): Promise => { + try { + // SSRF Protection: Validate URL before creating transport + const urlValidation = validateMcpServerUrl(server.url); + if (!urlValidation.valid) { + return null; // Skip this server + } - // Use the official MCP SDK transport for better compatibility - const transport = new StreamableHTTPClientTransport( - urlValidation.url!, - { - requestInit: { - headers: server.apiKey - ? { Authorization: `Bearer ${server.apiKey}` } - : undefined, - }, - }, - ); + // Use the official MCP SDK transport for better compatibility + const transport = new StreamableHTTPClientTransport( + urlValidation.url!, + { + requestInit: { + headers: server.apiKey + ? { Authorization: `Bearer ${server.apiKey}` } + : undefined, + }, + }, + ); - const clientPromise = createMCPClient({ transport }); + const clientPromise = createMCPClient({ transport }); - // Add 10 second timeout to prevent hanging - const timeoutPromise = new Promise((_, reject) => { - setTimeout( - () => - reject(new Error(`MCP connection timeout for ${server.name}`)), - 10000, - ); - }); + // Add 10 second timeout to prevent hanging + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => + reject( + new Error(`MCP connection timeout for ${server.name}`), + ), + 10000, + ); + }); - const client = await Promise.race([clientPromise, timeoutPromise]); - mcpClients.push({ client, name: server.name }); - } catch { - // Continue with other servers + const client = await Promise.race([clientPromise, timeoutPromise]); + return { client, name: server.name }; + } catch { + // Continue with other servers + return null; + } + }, + ), + ); + for (const wrapper of connectionResults) { + if (wrapper) { + mcpClients.push(wrapper); } } diff --git a/apps/playground/src/app/share/[shareId]/page.tsx b/apps/playground/src/app/share/[shareId]/page.tsx index 86300ab53e..80546b3f0c 100644 --- a/apps/playground/src/app/share/[shareId]/page.tsx +++ b/apps/playground/src/app/share/[shareId]/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { notFound } from "next/navigation"; +import { cache } from "react"; import { ReadOnlyChatMessages } from "@/components/playground/chat-ui"; import { ForkChatButton } from "@/components/playground/fork-chat-button"; @@ -119,25 +120,36 @@ function meetsIndexThreshold(messages: SharedMessage[]): boolean { return hasValidUserTurn && hasValidAssistantTurn; } +// cache() dedupes the generateMetadata + page calls within a request — the +// fetch itself is no-store, so nothing persists beyond the render pass. +const getSharedChat = cache( + async (shareId: string): Promise => { + const config = getConfig(); + const response = await fetch( + `${config.apiBackendUrl}/public/chats/share/${shareId}`, + { cache: "no-store" }, + ); + if (!response.ok) { + return null; + } + return (await response.json()) as SharedChatResponse; + }, +); + export async function generateMetadata({ params, }: { params: Promise<{ shareId: string }>; }): Promise { const { shareId } = await params; - const config = getConfig(); let title = "Shared Chat"; let description = FALLBACK_SHARE_DESCRIPTION; let indexable = true; try { - const response = await fetch( - `${config.apiBackendUrl}/public/chats/share/${shareId}`, - { cache: "no-store" }, - ); - if (response.ok) { - const data = (await response.json()) as SharedChatResponse; + const data = await getSharedChat(shareId); + if (data) { const flatTitle = data.share.title?.replace(/\s+/g, " ").trim(); if (flatTitle) { title = @@ -186,19 +198,12 @@ export default async function SharedChatPage({ params: Promise<{ shareId: string }>; }) { const { shareId } = await params; - const config = getConfig(); - const response = await fetch( - `${config.apiBackendUrl}/public/chats/share/${shareId}`, - { - cache: "no-store", - }, - ); + const data = await getSharedChat(shareId); - if (!response.ok) { + if (!data) { notFound(); } - const data = (await response.json()) as SharedChatResponse; const messages = data.share.messages.map(toUiMessage); const shareUrl = `https://lounge.llmgateway.io/share/${data.share.id}`; diff --git a/apps/playground/src/components/ai-elements/shimmer.tsx b/apps/playground/src/components/ai-elements/shimmer.tsx index 1858b382a5..d771714ef7 100644 --- a/apps/playground/src/components/ai-elements/shimmer.tsx +++ b/apps/playground/src/components/ai-elements/shimmer.tsx @@ -1,13 +1,7 @@ "use client"; import { motion } from "motion/react"; -import { - type CSSProperties, - type ElementType, - type JSX, - memo, - useMemo, -} from "react"; +import { type CSSProperties, type ElementType, type JSX, memo } from "react"; import { cn } from "@/lib/utils"; @@ -19,6 +13,23 @@ export interface TextShimmerProps { spread?: number; } +// motion.create returns a new component type each call, which would remount +// the subtree (and restart the animation) on every render if done inline — +// cache per element type at module level instead. +const motionComponentCache = new Map< + ElementType, + ReturnType +>(); + +function getMotionComponent(component: ElementType) { + let cached = motionComponentCache.get(component); + if (!cached) { + cached = motion.create(component as keyof JSX.IntrinsicElements); + motionComponentCache.set(component, cached); + } + return cached; +} + const ShimmerComponent = ({ children, as: Component = "p", @@ -26,14 +37,9 @@ const ShimmerComponent = ({ duration = 2, spread = 2, }: TextShimmerProps) => { - const MotionComponent = motion.create( - Component as keyof JSX.IntrinsicElements, - ); + const MotionComponent = getMotionComponent(Component); - const dynamicSpread = useMemo( - () => (children?.length ?? 0) * spread, - [children, spread], - ); + const dynamicSpread = (children?.length ?? 0) * spread; return ( mapModels(models, providers), [models, providers], ); - const [availableModels] = useState(mapped); // Chat always starts on Auto Route unless the URL pins a model; the last // selection is deliberately not persisted across visits. diff --git a/apps/playground/src/lib/escape-run.ts b/apps/playground/src/lib/escape-run.ts index 1dc11b1079..e1d195b40a 100644 --- a/apps/playground/src/lib/escape-run.ts +++ b/apps/playground/src/lib/escape-run.ts @@ -1,3 +1,5 @@ +import { cache } from "react"; + import { fetchServerData } from "@/lib/server-api"; export interface EscapeRunResponse { @@ -25,15 +27,16 @@ export interface EscapeRunResponse { * Loads a shared run. Both the run page and its OpenGraph image render from * this, so it lives here rather than on either route — importing it from the * page module would pull that page's client components into the image bundle. + * cache() dedupes the generateMetadata + page calls within a request. */ -export async function fetchEscapeRun( - runId: string, -): Promise { - return await fetchServerData( - "GET", - "/public/escape/runs/{id}", - { - params: { path: { id: runId } }, - }, - ); -} +export const fetchEscapeRun = cache( + async (runId: string): Promise => { + return await fetchServerData( + "GET", + "/public/escape/runs/{id}", + { + params: { path: { id: runId } }, + }, + ); + }, +); diff --git a/apps/ui/src/app/dashboard/[orgId]/layout.tsx b/apps/ui/src/app/dashboard/[orgId]/layout.tsx index 48b21fb2c5..4d80ac2348 100644 --- a/apps/ui/src/app/dashboard/[orgId]/layout.tsx +++ b/apps/ui/src/app/dashboard/[orgId]/layout.tsx @@ -1,17 +1,12 @@ import { DashboardLayoutClient } from "@/components/dashboard/dashboard-layout-client"; import { UnauthorizedView } from "@/components/dashboard/unauthorized-view"; import { UserProvider } from "@/components/providers/user-provider"; +import { getAnnouncementEntries } from "@/lib/announcements"; import { SidebarProvider } from "@/lib/components/sidebar"; import { getLastUsedProjectId } from "@/lib/last-used-project-server"; -import { - fetchServerData, - getOrganizations, - getOrgProjects, -} from "@/lib/server-api"; +import { getOrganizations, getOrgProjects, getUserMe } from "@/lib/server-api"; -import type { AnnouncementEntry } from "@/components/dashboard/changelog-notifications"; -import type { User, Project } from "@/lib/types"; -import type { Blog, Changelog } from "content-collections"; +import type { Project } from "@/lib/types"; import type { ReactNode } from "react"; interface OrgLayoutProps { @@ -22,9 +17,7 @@ interface OrgLayoutProps { export default async function OrgLayout({ children, params }: OrgLayoutProps) { const { orgId } = await params; - const initialUserDataPromise = fetchServerData< - { user: User } | undefined | null - >("GET", "/user/me"); + const initialUserDataPromise = getUserMe(); const initialOrganizationsDataPromise = getOrganizations(); @@ -76,37 +69,8 @@ export default async function OrgLayout({ children, params }: OrgLayoutProps) { } } - // Fetch recent changelog + blog entries for the notifications bell - let announcementEntries: AnnouncementEntry[] = []; - try { - const { allChangelogs, allBlogs } = await import("content-collections"); - - const changelogs: AnnouncementEntry[] = allChangelogs - .filter((entry: Changelog) => !entry?.draft) - .map((entry: Changelog) => ({ - slug: entry.slug, - title: entry.title, - summary: entry.summary, - date: entry.date, - type: "changelog" as const, - })); - - const blogs: AnnouncementEntry[] = allBlogs - .filter((entry: Blog) => !entry?.draft) - .map((entry: Blog) => ({ - slug: entry.slug, - title: entry.title, - summary: entry.summary, - date: entry.date, - type: "blog" as const, - })); - - announcementEntries = [...changelogs, ...blogs] - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) - .slice(0, 8); - } catch { - // Content collections may not be available during build - } + // Recent changelog + blog entries for the notifications bell + const announcementEntries = await getAnnouncementEntries(); return ( diff --git a/apps/ui/src/app/features/[slug]/page.tsx b/apps/ui/src/app/features/[slug]/page.tsx index d071ca5954..fd14bdaa89 100644 --- a/apps/ui/src/app/features/[slug]/page.tsx +++ b/apps/ui/src/app/features/[slug]/page.tsx @@ -6,16 +6,10 @@ import { ExternalLink, Sparkles, } from "lucide-react"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { ActivityLogsDemo } from "@/components/features/activity-logs-demo"; -import { ApiKeyDemo } from "@/components/features/api-key-demo"; -import { CostAnalyticsDemo } from "@/components/features/cost-analytics-demo"; -import { ErrorsMonitoringDemo } from "@/components/features/errors-monitoring-demo"; -import { ModelBreakdownDemo } from "@/components/features/model-breakdown-demo"; -import { MultiProviderDemo } from "@/components/features/multi-provider-demo"; -import { PerformanceMonitoringDemo } from "@/components/features/performance-monitoring-demo"; import Footer from "@/components/landing/footer"; import { Navbar } from "@/components/landing/navbar"; import { Badge } from "@/lib/components/badge"; @@ -29,14 +23,43 @@ interface PageProps { params: Promise<{ slug: string }>; } +// Only the demo matching the route is rendered, so load each one lazily +// instead of bundling all of them (two pull in recharts) into every +// feature page. const demoComponents = { - "multi-provider": MultiProviderDemo, - "performance-monitoring": PerformanceMonitoringDemo, - "api-key": ApiKeyDemo, - "cost-analytics": CostAnalyticsDemo, - "model-breakdown": ModelBreakdownDemo, - "errors-monitoring": ErrorsMonitoringDemo, - "activity-logs": ActivityLogsDemo, + "multi-provider": dynamic(() => + import("@/components/features/multi-provider-demo").then( + (mod) => mod.MultiProviderDemo, + ), + ), + "performance-monitoring": dynamic(() => + import("@/components/features/performance-monitoring-demo").then( + (mod) => mod.PerformanceMonitoringDemo, + ), + ), + "api-key": dynamic(() => + import("@/components/features/api-key-demo").then((mod) => mod.ApiKeyDemo), + ), + "cost-analytics": dynamic(() => + import("@/components/features/cost-analytics-demo").then( + (mod) => mod.CostAnalyticsDemo, + ), + ), + "model-breakdown": dynamic(() => + import("@/components/features/model-breakdown-demo").then( + (mod) => mod.ModelBreakdownDemo, + ), + ), + "errors-monitoring": dynamic(() => + import("@/components/features/errors-monitoring-demo").then( + (mod) => mod.ErrorsMonitoringDemo, + ), + ), + "activity-logs": dynamic(() => + import("@/components/features/activity-logs-demo").then( + (mod) => mod.ActivityLogsDemo, + ), + ), "audit-logs": null, guardrails: null, }; diff --git a/apps/ui/src/app/models/[name]/uptime/page.tsx b/apps/ui/src/app/models/[name]/uptime/page.tsx index b2c5c5ee30..dda3418720 100644 --- a/apps/ui/src/app/models/[name]/uptime/page.tsx +++ b/apps/ui/src/app/models/[name]/uptime/page.tsx @@ -6,12 +6,12 @@ import { ShieldCheck, Zap, } from "lucide-react"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { notFound } from "next/navigation"; import Footer from "@/components/landing/footer"; import { Navbar } from "@/components/landing/navbar"; -import { ModelUptimeCharts } from "@/components/models/model-uptime-charts"; import { Badge } from "@/lib/components/badge"; import { Card, @@ -30,6 +30,14 @@ import { import type { Metadata } from "next"; +// The uptime charts pull in recharts; load them lazily so the chart library +// stays out of the route's initial bundle. +const ModelUptimeCharts = dynamic(() => + import("@/components/models/model-uptime-charts").then( + (mod) => mod.ModelUptimeCharts, + ), +); + interface PageProps { params: Promise<{ name: string }>; } diff --git a/apps/ui/src/app/rankings/page.tsx b/apps/ui/src/app/rankings/page.tsx index d0758e9c53..ea50020eb5 100644 --- a/apps/ui/src/app/rankings/page.tsx +++ b/apps/ui/src/app/rankings/page.tsx @@ -1,9 +1,7 @@ +import dynamic from "next/dynamic"; + import Footer from "@/components/landing/footer"; import { Navbar } from "@/components/landing/navbar"; -import { - RankingsContent, - type RankingsModelMeta, -} from "@/components/rankings/rankings-content"; import { JsonLd } from "@/components/seo/json-ld"; import { fetchServerData } from "@/lib/server-api"; @@ -13,8 +11,17 @@ import { type ModelDefinition, } from "@llmgateway/models"; +import type { RankingsModelMeta } from "@/components/rankings/rankings-content"; import type { Metadata } from "next"; +// The rankings list pulls in recharts; load it lazily so the chart library +// stays out of the route's initial bundle. +const RankingsContent = dynamic(() => + import("@/components/rankings/rankings-content").then( + (mod) => mod.RankingsContent, + ), +); + export const revalidate = 300; const title = "LLM Rankings — Top Models by Real Usage"; diff --git a/apps/ui/src/components/landing/code-example.tsx b/apps/ui/src/components/landing/code-example.tsx index 350cfa064f..ba199a11b9 100644 --- a/apps/ui/src/components/landing/code-example.tsx +++ b/apps/ui/src/components/landing/code-example.tsx @@ -3,7 +3,6 @@ import { Check, Copy } from "lucide-react"; import { useTheme } from "next-themes"; import { useState, useEffect, Fragment } from "react"; -import { createHighlighter } from "shiki"; import { Button } from "@/lib/components/button"; import { toast } from "@/lib/components/use-toast"; @@ -218,12 +217,16 @@ const highlightLangs: BundledLanguage[] = [ let highlighterPromise: Promise | null = null; +// Load shiki lazily so the highlighter engine stays out of the landing +// page's initial bundle; highlighting fills in once it resolves. function getHighlighter() { if (!highlighterPromise) { - highlighterPromise = createHighlighter({ - langs: highlightLangs, - themes: ["dracula", "github-light"], - }); + highlighterPromise = import("shiki").then(({ createHighlighter }) => + createHighlighter({ + langs: highlightLangs, + themes: ["dracula", "github-light"], + }), + ); } return highlighterPromise; } diff --git a/apps/ui/src/hooks/useUser.ts b/apps/ui/src/hooks/useUser.ts index 29f3116baf..252c52338a 100644 --- a/apps/ui/src/hooks/useUser.ts +++ b/apps/ui/src/hooks/useUser.ts @@ -69,21 +69,26 @@ export function useUser(options?: UseUserOptions) { }, ); - if (data) { - posthog.identify(data.user.id, { - email: data.user.email, - name: data.user.name, - onboarding_completed: data.user.onboardingCompleted, - }); - } + useEffect(() => { + if (data) { + posthog.identify(data.user.id, { + email: data.user.email, + name: data.user.name, + onboarding_completed: data.user.onboardingCompleted, + }); + } + }, [data, posthog]); + + const redirectTo = options?.redirectTo; + const redirectWhen = options?.redirectWhen; + const checkOnboarding = options?.checkOnboarding; // Handle existing redirect logic useEffect(() => { - if (!options?.redirectTo || !options?.redirectWhen) { + if (!redirectTo || !redirectWhen) { return; } - const { redirectTo, redirectWhen, checkOnboarding } = options; const hasUser = !!data?.user; if (redirectWhen === "authenticated" && hasUser && !isLoading && !error) { @@ -104,10 +109,9 @@ export function useUser(options?: UseUserOptions) { isLoading, error, router, - options?.redirectTo, - options?.redirectWhen, - options?.checkOnboarding, - options, + redirectTo, + redirectWhen, + checkOnboarding, ]); return { diff --git a/apps/ui/src/lib/announcements.ts b/apps/ui/src/lib/announcements.ts new file mode 100644 index 0000000000..41ef1e1632 --- /dev/null +++ b/apps/ui/src/lib/announcements.ts @@ -0,0 +1,43 @@ +import type { AnnouncementEntry } from "@/components/dashboard/changelog-notifications"; +import type { Blog, Changelog } from "content-collections"; + +let cached: AnnouncementEntry[] | null = null; + +// Content collections are static per build, so build the notification-bell +// entries once per process instead of on every dashboard request. +export async function getAnnouncementEntries(): Promise { + if (cached) { + return cached; + } + try { + const { allChangelogs, allBlogs } = await import("content-collections"); + + const changelogs: AnnouncementEntry[] = allChangelogs + .filter((entry: Changelog) => !entry?.draft) + .map((entry: Changelog) => ({ + slug: entry.slug, + title: entry.title, + summary: entry.summary, + date: entry.date, + type: "changelog" as const, + })); + + const blogs: AnnouncementEntry[] = allBlogs + .filter((entry: Blog) => !entry?.draft) + .map((entry: Blog) => ({ + slug: entry.slug, + title: entry.title, + summary: entry.summary, + date: entry.date, + type: "blog" as const, + })); + + cached = [...changelogs, ...blogs] + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .slice(0, 8); + } catch { + // Content collections may not be available during build + cached = []; + } + return cached; +} diff --git a/apps/ui/src/lib/getUser.ts b/apps/ui/src/lib/getUser.ts index da765939c3..64ac952ffd 100644 --- a/apps/ui/src/lib/getUser.ts +++ b/apps/ui/src/lib/getUser.ts @@ -1,12 +1,10 @@ import PostHogClient from "@/app/posthog"; -import { fetchServerData } from "@/lib/server-api"; - -import type { User } from "better-auth/types"; +import { getUserMe } from "@/lib/server-api"; export async function getUser() { const posthog = PostHogClient(); - const data = await fetchServerData<{ user: User }>("GET", "/user/me"); + const data = await getUserMe(); const user = data?.user; if (!user) { diff --git a/apps/ui/src/lib/server-api.ts b/apps/ui/src/lib/server-api.ts index b911ac73dd..1f8da44e7e 100644 --- a/apps/ui/src/lib/server-api.ts +++ b/apps/ui/src/lib/server-api.ts @@ -5,7 +5,7 @@ import { cache } from "react"; import { getConfig } from "./config-server"; import type { paths } from "./api/v1"; -import type { Organization, Project } from "./types"; +import type { Organization, Project, User } from "./types"; // Server-side API client export async function createServerApiClient() { @@ -142,6 +142,12 @@ export function getProject(projectId: string) { ); } +export function getUserMe() { + return dedupeRequest("userMe", () => + fetchServerData<{ user: User }>("GET", "/user/me"), + ); +} + export function getOrganizations() { return dedupeRequest("orgs", () => fetchServerData<{ organizations: Organization[] }>("GET", "/orgs"), From f0b30b5559211f02ffb0399560334afa031d1bd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 06:36:54 +0000 Subject: [PATCH 2/4] fix: preserve motion.create prop inference --- apps/playground/src/components/ai-elements/shimmer.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/playground/src/components/ai-elements/shimmer.tsx b/apps/playground/src/components/ai-elements/shimmer.tsx index d771714ef7..f4c36733ba 100644 --- a/apps/playground/src/components/ai-elements/shimmer.tsx +++ b/apps/playground/src/components/ai-elements/shimmer.tsx @@ -16,15 +16,19 @@ export interface TextShimmerProps { // motion.create returns a new component type each call, which would remount // the subtree (and restart the animation) on every render if done inline — // cache per element type at module level instead. +function createMotionComponent(component: keyof JSX.IntrinsicElements) { + return motion.create(component); +} + const motionComponentCache = new Map< ElementType, - ReturnType + ReturnType >(); function getMotionComponent(component: ElementType) { let cached = motionComponentCache.get(component); if (!cached) { - cached = motion.create(component as keyof JSX.IntrinsicElements); + cached = createMotionComponent(component as keyof JSX.IntrinsicElements); motionComponentCache.set(component, cached); } return cached; From 093cd5401bb1a57d8106e1fa8e7c55dacaffdf42 Mon Sep 17 00:00:00 2001 From: smakosh Date: Thu, 20 Aug 2026 21:21:16 +0200 Subject: [PATCH 3/4] fix(playground): address review comments Tear down the MCP transport and any late-resolving client when the connection timeout wins the race (and clear the timer when it loses), and fetch shared chats through the typed server API client instead of raw fetch. Claude-Session: https://claude.ai/code/session_018aDkAsZnJejEty8hGCE34h --- apps/playground/src/app/api/chat/route.ts | 23 ++++++++++++++++--- .../src/app/share/[shareId]/page.tsx | 17 +++++++------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/playground/src/app/api/chat/route.ts b/apps/playground/src/app/api/chat/route.ts index a0c7c58356..595f4f30f9 100644 --- a/apps/playground/src/app/api/chat/route.ts +++ b/apps/playground/src/app/api/chat/route.ts @@ -1032,8 +1032,9 @@ export async function POST(req: Request) { const clientPromise = createMCPClient({ transport }); // Add 10 second timeout to prevent hanging + let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { - setTimeout( + timeoutId = setTimeout( () => reject( new Error(`MCP connection timeout for ${server.name}`), @@ -1042,8 +1043,24 @@ export async function POST(req: Request) { ); }); - const client = await Promise.race([clientPromise, timeoutPromise]); - return { client, name: server.name }; + try { + const client = await Promise.race([ + clientPromise, + timeoutPromise, + ]); + return { client, name: server.name }; + } catch { + // Timeout or connection failure: losing the race does not + // cancel the connection attempt, so tear down the transport + // and close a client that may still resolve later. + void transport.close().catch(() => {}); + void clientPromise + .then((client) => client.close()) + .catch(() => {}); + return null; + } finally { + clearTimeout(timeoutId); + } } catch { // Continue with other servers return null; diff --git a/apps/playground/src/app/share/[shareId]/page.tsx b/apps/playground/src/app/share/[shareId]/page.tsx index 80546b3f0c..1e342f188a 100644 --- a/apps/playground/src/app/share/[shareId]/page.tsx +++ b/apps/playground/src/app/share/[shareId]/page.tsx @@ -5,8 +5,8 @@ import { cache } from "react"; import { ReadOnlyChatMessages } from "@/components/playground/chat-ui"; import { ForkChatButton } from "@/components/playground/fork-chat-button"; import { Wordmark } from "@/components/ui/wordmark"; -import { getConfig } from "@/lib/config-server"; import { parsePlaygroundMessageMetadata } from "@/lib/message-metadata"; +import { fetchServerData } from "@/lib/server-api"; import type { UIMessage } from "ai"; import type { Metadata } from "next"; @@ -124,15 +124,14 @@ function meetsIndexThreshold(messages: SharedMessage[]): boolean { // fetch itself is no-store, so nothing persists beyond the render pass. const getSharedChat = cache( async (shareId: string): Promise => { - const config = getConfig(); - const response = await fetch( - `${config.apiBackendUrl}/public/chats/share/${shareId}`, - { cache: "no-store" }, + return await fetchServerData( + "GET", + "/public/chats/share/{shareId}", + { + params: { path: { shareId } }, + cache: "no-store", + }, ); - if (!response.ok) { - return null; - } - return (await response.json()) as SharedChatResponse; }, ); From 87a5e2996b738ac82f626de920c413837f8de9ba Mon Sep 17 00:00:00 2001 From: smakosh Date: Thu, 20 Aug 2026 21:56:27 +0200 Subject: [PATCH 4/4] fix(playground): typed share schema, 404-only notFound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive the shared-chat wire types from the generated OpenAPI schema instead of hand-written duplicates, and only map a confirmed 404 to notFound() — transient API failures now throw to the error boundary. Claude-Session: https://claude.ai/code/session_018aDkAsZnJejEty8hGCE34h --- .../src/app/share/[shareId]/page.tsx | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/apps/playground/src/app/share/[shareId]/page.tsx b/apps/playground/src/app/share/[shareId]/page.tsx index 1e342f188a..24458e3585 100644 --- a/apps/playground/src/app/share/[shareId]/page.tsx +++ b/apps/playground/src/app/share/[shareId]/page.tsx @@ -6,24 +6,17 @@ import { ReadOnlyChatMessages } from "@/components/playground/chat-ui"; import { ForkChatButton } from "@/components/playground/fork-chat-button"; import { Wordmark } from "@/components/ui/wordmark"; import { parsePlaygroundMessageMetadata } from "@/lib/message-metadata"; -import { fetchServerData } from "@/lib/server-api"; +import { createServerApiClient } from "@/lib/server-api"; +import type { paths } from "@/lib/api/v1"; import type { UIMessage } from "ai"; import type { Metadata } from "next"; -interface SharedMessage { - id: string; - role: "user" | "assistant" | "system"; - content: string | null; - images: string | null; - audios: string | null; - documents: string | null; - reasoning: string | null; - tools: string | null; - metadata?: unknown; - sequence: number; - createdAt: string; -} +// Wire shapes come from the generated OpenAPI schema so optional fields +// (sources, audios, documents, metadata) stay in sync with the endpoint. +type SharedChatResponse = + paths["/public/chats/share/{shareId}"]["get"]["responses"]["200"]["content"]["application/json"]; +type SharedMessage = SharedChatResponse["share"]["messages"][number]; interface StoredAudioPart { type?: string; @@ -39,16 +32,6 @@ interface StoredDocumentPart { name?: string; } -interface SharedChatResponse { - share: { - id: string; - title: string; - model: string; - createdAt: string; - messages: SharedMessage[]; - }; -} - interface StoredImagePart { image_url?: { url?: string; @@ -124,14 +107,23 @@ function meetsIndexThreshold(messages: SharedMessage[]): boolean { // fetch itself is no-store, so nothing persists beyond the render pass. const getSharedChat = cache( async (shareId: string): Promise => { - return await fetchServerData( - "GET", + const client = await createServerApiClient(); + const { data, response } = await client.GET( "/public/chats/share/{shareId}", { params: { path: { shareId } }, cache: "no-store", }, ); + if (data) { + return data; + } + // Only a confirmed missing share renders the 404 page; transient API + // failures throw so they reach the error boundary instead. + if (response.status === 404) { + return null; + } + throw new Error(`Failed to load shared chat (${response.status})`); }, );