From 6945a582a82bac36696a54f9311fa1840d57c786 Mon Sep 17 00:00:00 2001 From: priority3 Date: Thu, 5 Feb 2026 20:43:35 +0800 Subject: [PATCH] perf: Optimize bundle size and loading performance - Add dynamic imports for heavy components (MapLibre, Recharts, ArtGallery) - Remove unused @turf/turf dependency (~200KB) - Move playwright to devDependencies - Add optimizePackageImports for lucide-react, recharts, framer-motion - Simplify skeleton.tsx to use CSS-only animations - Increase staleTime from 5s to 60s to reduce refetches - Add data prefetching on activity card hover - Fix type errors in fingerprint.ts Co-Authored-By: Claude Opus 4.5 --- next.config.ts | 13 ++++++ package.json | 3 +- src/app/activity/[id]/page.tsx | 55 +++++++++++++++++++---- src/app/page.tsx | 35 ++++++++++++--- src/components/activity/ActivityTable.tsx | 19 +++++++- src/components/ui/skeleton.tsx | 52 +++------------------ src/lib/art/fingerprint.ts | 4 +- src/lib/trpc/Provider.tsx | 4 +- 8 files changed, 119 insertions(+), 66 deletions(-) diff --git a/next.config.ts b/next.config.ts index bba4e73..963f212 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,19 @@ const nextConfig: NextConfig = { typedRoutes: true, // Enable standalone output for Docker deployment output: 'standalone', + experimental: { + // Optimize tree-shaking for common packages + // Reason: These packages export many modules, but we only use a subset + optimizePackageImports: [ + 'lucide-react', + 'recharts', + 'framer-motion', + '@radix-ui/react-tabs', + '@radix-ui/react-dialog', + '@radix-ui/react-popover', + '@radix-ui/react-tooltip', + ], + }, } export default nextConfig diff --git a/package.json b/package.json index d255485..1da4fe6 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "@trpc/client": "^11.0.0", "@trpc/react-query": "^11.0.0", "@trpc/server": "^11.0.0", - "@turf/turf": "^7.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.38.4", @@ -40,7 +39,6 @@ "maplibre-gl": "^5.0.1", "nanoid": "^5.1.6", "next": "16.1.0-canary.34", - "playwright": "^1.58.0", "react": "^19.0.0-rc.1", "react-dom": "^19.0.0-rc.1", "react-map-gl": "^8.1.0", @@ -62,6 +60,7 @@ "eslint": "^9.18.0", "eslint-config-hyoban": "^4.0.10", "lint-staged": "^16.0.2", + "playwright": "^1.58.0", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.10", "simple-git-hooks": "^2.13.1", diff --git a/src/app/activity/[id]/page.tsx b/src/app/activity/[id]/page.tsx index f73773c..6bebf81 100644 --- a/src/app/activity/[id]/page.tsx +++ b/src/app/activity/[id]/page.tsx @@ -9,23 +9,38 @@ import { motion } from 'framer-motion' import { useAtom } from 'jotai' import { ArrowLeft, Pause, Play, Square } from 'lucide-react' +import dynamic from 'next/dynamic' import { useParams, useRouter } from 'next/navigation' import { useEffect, useMemo, useState } from 'react' import { ActivityActionBar } from '@/components/activity/ActivityActionBar' -import { AIInsight } from '@/components/activity/AIInsight' -import { HeartRateChart } from '@/components/activity/HeartRateChart' -import { HeartRateZones } from '@/components/activity/HeartRateZones' import { PaceChart } from '@/components/activity/PaceChart' -import { PaceDistribution } from '@/components/activity/PaceDistribution' import { SplitsTable } from '@/components/activity/SplitsTable' -import { ArtGallery } from '@/components/art' -import { AnimatedRoute } from '@/components/map/AnimatedRoute' import { FloatingInfoCard } from '@/components/map/FloatingInfoCard' -import { KilometerMarkers } from '@/components/map/KilometerMarkers' -import { PaceRouteLayer } from '@/components/map/PaceRouteLayer' -import { RunMap } from '@/components/map/RunMap' import { AnimatedTabs, AnimatedTabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' + +// Lazy load map components - MapLibre GL is ~60KB gzipped +const RunMap = dynamic( + () => import('@/components/map/RunMap').then((m) => ({ default: m.RunMap })), + { + ssr: false, + loading: () => ( +
+ ), + }, +) + +const AnimatedRoute = dynamic(() => + import('@/components/map/AnimatedRoute').then((m) => ({ default: m.AnimatedRoute })), +) + +const PaceRouteLayer = dynamic(() => + import('@/components/map/PaceRouteLayer').then((m) => ({ default: m.PaceRouteLayer })), +) + +const KilometerMarkers = dynamic(() => + import('@/components/map/KilometerMarkers').then((m) => ({ default: m.KilometerMarkers })), +) import { useActivityWithSplits } from '@/hooks/use-activities' import { springs } from '@/lib/animation' import { generateMockTrackPoints } from '@/lib/map/mock-data' @@ -37,6 +52,28 @@ import { formatDate, formatTime } from '@/lib/utils' import { animationProgressAtom, isPlayingAtom } from '@/stores/map' import type { Split } from '@/types/activity' +// Lazy load non-default tab components to reduce initial bundle +// Reason: Recharts (~40KB gz) and other heavy components shouldn't load until user clicks the tab +const HeartRateChart = dynamic(() => + import('@/components/activity/HeartRateChart').then((m) => ({ default: m.HeartRateChart })), +) + +const HeartRateZones = dynamic(() => + import('@/components/activity/HeartRateZones').then((m) => ({ default: m.HeartRateZones })), +) + +const PaceDistribution = dynamic(() => + import('@/components/activity/PaceDistribution').then((m) => ({ default: m.PaceDistribution })), +) + +const ArtGallery = dynamic(() => + import('@/components/art/ArtGallery').then((m) => ({ default: m.ArtGallery })), +) + +const AIInsight = dynamic(() => + import('@/components/activity/AIInsight').then((m) => ({ default: m.AIInsight })), +) + export default function ActivityDetailPage() { const params = useParams() const router = useRouter() diff --git a/src/app/page.tsx b/src/app/page.tsx index 8ec5c52..e3c9c4f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,22 +9,47 @@ import { AnimatePresence, motion } from 'framer-motion' import { Activity, Calendar, Clock, Layers, MapPin } from 'lucide-react' +import dynamic from 'next/dynamic' import { useMemo, useState } from 'react' -import { ActivityHeatmap } from '@/components/activity/ActivityHeatmap' import { ActivityTable } from '@/components/activity/ActivityTable' -import { PersonalRecords } from '@/components/activity/PersonalRecords' import { StatsCard } from '@/components/activity/StatsCard' import { Header } from '@/components/layout/Header' -import { PaceRouteLayer } from '@/components/map/PaceRouteLayer' -import { RouteLayer } from '@/components/map/RouteLayer' -import { RunMap } from '@/components/map/RunMap' import { useActivities, useActivityStats } from '@/hooks/use-activities' import { parsePaceSegments } from '@/lib/map/pace-utils' import { cn } from '@/lib/utils' import type { Activity as ActivityType } from '@/types/activity' import type { RouteData } from '@/types/map' +// Lazy load heavy components to reduce initial bundle size +// Reason: MapLibre GL (~60KB gz) + react-map-gl should not block first paint +const RunMap = dynamic( + () => import('@/components/map/RunMap').then((m) => ({ default: m.RunMap })), + { + ssr: false, + loading: () => ( +
+ ), + }, +) + +const RouteLayer = dynamic(() => + import('@/components/map/RouteLayer').then((m) => ({ default: m.RouteLayer })), +) + +const PaceRouteLayer = dynamic(() => + import('@/components/map/PaceRouteLayer').then((m) => ({ default: m.PaceRouteLayer })), +) + +// Reason: Below-the-fold components don't need eager loading +const ActivityHeatmap = dynamic(() => + import('@/components/activity/ActivityHeatmap').then((m) => ({ default: m.ActivityHeatmap })), +) + +const PersonalRecords = dynamic(() => + import('@/components/activity/PersonalRecords').then((m) => ({ default: m.PersonalRecords })), +) + type StatsPeriod = 'week' | 'month' type MapLayerMode = 'route' | 'pace' diff --git a/src/components/activity/ActivityTable.tsx b/src/components/activity/ActivityTable.tsx index 23971b7..1f653a0 100644 --- a/src/components/activity/ActivityTable.tsx +++ b/src/components/activity/ActivityTable.tsx @@ -20,10 +20,12 @@ import { Zap, } from 'lucide-react' import Link from 'next/link' +import { useCallback } from 'react' import { RippleContainer } from '@/components/ui/ripple' import { layoutTransition, springs } from '@/lib/animation' import { calculatePace, formatDuration, formatPace } from '@/lib/pace/calculator' +import { trpc } from '@/lib/trpc/client' import type { Activity } from '@/types/activity' /** @@ -181,6 +183,18 @@ export function ActivityTable({ activities, className = '' }: ActivityTableProps // Calculate achievements for all activities const achievements = calculateAchievements(activities) + // Get trpc utils for prefetching + const trpcUtils = trpc.useUtils() + + // Prefetch activity data on hover for faster navigation + const handleMouseEnter = useCallback( + (activityId: string) => { + // Prefetch activity with splits data + trpcUtils.activities.getWithSplits.prefetch({ id: activityId }) + }, + [trpcUtils], + ) + if (activities.length === 0) { return ( - + handleMouseEnter(activity.id)} + > { @@ -34,18 +28,14 @@ export function Skeleton({ variant = 'default', width, height, - onAnimationStart, - onAnimationEnd, ...props }: SkeletonProps) { - const prefersReducedMotion = useReducedMotion() - const styles = { width: width || undefined, height: height || undefined, } - const baseClasses = 'relative overflow-hidden bg-fill' + const baseClasses = 'relative overflow-hidden bg-fill animate-pulse' const variantClasses = { default: 'rounded-xl', @@ -54,42 +44,12 @@ export function Skeleton({ rectangular: 'rounded-2xl', } - // Use simple pulse for reduced motion - if (prefersReducedMotion) { - return ( -
- ) - } - return ( - - - + {...props} + /> ) } diff --git a/src/lib/art/fingerprint.ts b/src/lib/art/fingerprint.ts index 9a0de6c..59b9ece 100644 --- a/src/lib/art/fingerprint.ts +++ b/src/lib/art/fingerprint.ts @@ -231,7 +231,7 @@ export function drawFingerprint( const rotationsPerKm = 0.5 // Each km = half rotation (180°) const totalRotations = rings.length * rotationsPerKm const startRadius = rings[0].innerRadius - const endRadius = rings[rings.length - 1].outerRadius + const endRadius = rings.at(-1)!.outerRadius const totalAngle = totalRotations * Math.PI * 2 // High resolution for smooth spiral @@ -382,7 +382,7 @@ export function drawFingerprintAnimated( const rotationsPerKm = 0.5 const totalRotations = rings.length * rotationsPerKm const startRadius = rings[0].innerRadius - const endRadius = rings[rings.length - 1].outerRadius + const endRadius = rings.at(-1)!.outerRadius const totalAngle = totalRotations * Math.PI * 2 const pointsPerRotation = 120 diff --git a/src/lib/trpc/Provider.tsx b/src/lib/trpc/Provider.tsx index b543460..aef742b 100644 --- a/src/lib/trpc/Provider.tsx +++ b/src/lib/trpc/Provider.tsx @@ -29,7 +29,9 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) { new QueryClient({ defaultOptions: { queries: { - staleTime: 5 * 1000, // 5 seconds + // 60 seconds - running activity data doesn't change frequently + // Reason: Reduces unnecessary refetches, previous 5s was too aggressive + staleTime: 60 * 1000, refetchOnWindowFocus: false, }, },