From 6afd515b6278a777e7fafd367b3daae68897ea75 Mon Sep 17 00:00:00 2001 From: furyhawk Date: Thu, 23 Apr 2026 20:50:23 +0800 Subject: [PATCH] perf: optimize frontend with code splitting, memoization, and tree shaking - Implemented lazy loading for weather hub tabs - Added React.memo to weather components for performance - Optimized data processing with useMemo in dashboard - Enabled tree-shaking in Vite build - Improved bundle size by removing unused imports --- frontend/package-lock.json | 8 +- frontend/package.json | 2 +- .../components/dashboard/FuryDashboard.tsx | 45 ++++--- .../src/components/weather/AirTemperature.tsx | 30 +++-- .../components/weather/TwoHourForecast.tsx | 25 ++-- .../src/components/weather/WeatherMap.tsx | 110 +++++++++++------- frontend/src/routes/_layout/weather-hub.tsx | 38 ++++-- frontend/vite.config.ts | 20 +++- 8 files changed, 185 insertions(+), 93 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 03a165c..65f0784 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,7 +36,7 @@ "@types/react-dom": "^19.1.5", "@vitejs/plugin-react-swc": "^3.9.0", "dotenv": "^16.5.0", - "typescript": "^5.8.3", + "typescript": "^5.9.3", "vite": "^6.3.5" } }, @@ -5095,9 +5095,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/frontend/package.json b/frontend/package.json index af63951..7ba40f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,7 +39,7 @@ "@types/react-dom": "^19.1.5", "@vitejs/plugin-react-swc": "^3.9.0", "dotenv": "^16.5.0", - "typescript": "^5.8.3", + "typescript": "^5.9.3", "vite": "^6.3.5" } } diff --git a/frontend/src/components/dashboard/FuryDashboard.tsx b/frontend/src/components/dashboard/FuryDashboard.tsx index 7d4f410..e0c581b 100644 --- a/frontend/src/components/dashboard/FuryDashboard.tsx +++ b/frontend/src/components/dashboard/FuryDashboard.tsx @@ -1,4 +1,4 @@ -import { +import React, { Badge, Box, Button, @@ -14,7 +14,7 @@ import { VStack, } from "@chakra-ui/react" import { useQuery } from "@tanstack/react-query" -import { useState } from "react" +import { useState, useMemo } from "react" import useAuth from "@/hooks/useAuth" @@ -160,11 +160,31 @@ function FuryDashboard() { retry: 1, }) + // Memoize the data processing to avoid recomputing on every render + const processedData = useMemo(() => { + if (!data) return null; + + const latestSampleTimes = Object.values(data.metrics) + .map((metric) => metric.latest?.updateTime) + .filter((value): value is string => Boolean(value)) + .sort((left, right) => + new Date(right).getTime() - new Date(left).getTime(), + ) + const newestSampleTime = latestSampleTimes[0] ?? data.generatedAt + const welcomeName = currentUser?.full_name || currentUser?.email || "there" + + return { + ...data, + newestSampleTime, + welcomeName + } + }, [data, currentUser]) + if (isLoading) { return } - if (error || !data) { + if (error || !processedData) { return ( @@ -182,14 +202,7 @@ function FuryDashboard() { ) } - const latestSampleTimes = Object.values(data.metrics) - .map((metric) => metric.latest?.updateTime) - .filter((value): value is string => Boolean(value)) - .sort((left, right) => - new Date(right).getTime() - new Date(left).getTime(), - ) - const newestSampleTime = latestSampleTimes[0] ?? data.generatedAt - const welcomeName = currentUser?.full_name || currentUser?.email || "there" + const { generatedAt, metrics, recentSamples, newestSampleTime, welcomeName } = processedData return ( @@ -248,12 +261,12 @@ function FuryDashboard() { ))} - {buildComfortSummary(data)} + {buildComfortSummary(processedData)} - {Object.values(data.metrics).map((metric) => ( + {Object.values(metrics).map((metric) => ( @@ -341,7 +354,7 @@ function FuryDashboard() { - Last fetch {formatTimestamp(data.generatedAt)} + Last fetch {formatTimestamp(generatedAt)} @@ -355,7 +368,7 @@ function FuryDashboard() { - {data.recentSamples.map((sample) => ( + {recentSamples.map((sample) => ( {formatTimestamp(sample.updateTime)} {sample.temperature ?? "--"} @@ -372,4 +385,4 @@ function FuryDashboard() { ) } -export default FuryDashboard \ No newline at end of file +export default React.memo(FuryDashboard) \ No newline at end of file diff --git a/frontend/src/components/weather/AirTemperature.tsx b/frontend/src/components/weather/AirTemperature.tsx index 06f7ae5..7ff2bea 100644 --- a/frontend/src/components/weather/AirTemperature.tsx +++ b/frontend/src/components/weather/AirTemperature.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Spinner, Text, Box, VStack, SimpleGrid, Heading } from '@chakra-ui/react'; import { Card } from '@chakra-ui/react'; @@ -18,18 +18,30 @@ const AirTemperature: React.FC = () => { refetchInterval: 1000 * 60 * 30, }); + // Memoize the data processing to avoid recomputing on every render + const processedData = useMemo(() => { + if (!data?.data) return { stations: [], readings: [], latestReading: null, avgTemp: 0 }; + + const { stations = [], readings = [] } = data.data; + const latestReading = readings[0]; + const validReadings = latestReading?.data?.filter(r => r.value !== null) || []; + const avgTemp = validReadings.length > 0 + ? validReadings.reduce((sum, r) => sum + r.value, 0) / validReadings.length + : 0; + + return { stations, readings, latestReading, avgTemp }; + }, [data]); + if (isLoading) return ; - if (error || !data?.data) return Error loading temperature data; + if (error || !processedData.stations.length || !processedData.readings.length) return Error loading temperature data; - const { stations = [], readings = [] } = data.data; - if (stations.length === 0 || readings.length === 0) return No temperature data available; - - const latestReading = readings[0]; + const { stations, latestReading } = processedData; + if (!latestReading || !latestReading.data) return No temperature data available; const stationMap = new Map(stations.map(s => [s.id, s.name])); const validReadings = latestReading.data.filter(r => r.value !== null); - const avgTemp = validReadings.length > 0 + const avgTempValue = validReadings.length > 0 ? validReadings.reduce((sum, r) => sum + r.value, 0) / validReadings.length : 0; @@ -39,7 +51,7 @@ const AirTemperature: React.FC = () => { Average Temperature - {avgTemp.toFixed(1)}°C + {avgTempValue.toFixed(1)}°C Based on {validReadings.length} station readings Last updated: {new Date(latestReading.timestamp).toLocaleString()} @@ -61,4 +73,4 @@ const AirTemperature: React.FC = () => { ); }; -export default AirTemperature; +export default React.memo(AirTemperature); diff --git a/frontend/src/components/weather/TwoHourForecast.tsx b/frontend/src/components/weather/TwoHourForecast.tsx index 3a0f93d..1e1c773 100644 --- a/frontend/src/components/weather/TwoHourForecast.tsx +++ b/frontend/src/components/weather/TwoHourForecast.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Box, Flex, Text, HStack, Badge, SimpleGrid, Spinner } from '@chakra-ui/react'; import { WeatherService } from '@/client/sdk.gen'; @@ -21,14 +21,21 @@ const TwoHourForecast: React.FC = () => { refetchInterval: 1000 * 60 * 30, }); + // Memoize the data processing to avoid recomputing on every render + const processedData = useMemo(() => { + if (!data?.data) return { items: [], forecasts: [], validPeriod: null }; + + const items = data.data.items || []; + const forecasts = items[0]?.forecasts || []; + const validPeriod = items[0]?.valid_period || null; + + return { items, forecasts, validPeriod }; + }, [data]); + if (isLoading) return ; - if (error || !data?.data) return Error loading forecast data; + if (error || !processedData.items.length) return Error loading forecast data; - const items = data.data.items || []; - if (!items.length) return No forecast data available; - - const forecasts = items[0].forecasts || []; - const validPeriod = items[0].valid_period; + const { forecasts, validPeriod } = processedData; return ( @@ -65,11 +72,11 @@ const TwoHourForecast: React.FC = () => { )} {viewMode === 'map' && ( - + )} ); }; -export default TwoHourForecast; +export default React.memo(TwoHourForecast); diff --git a/frontend/src/components/weather/WeatherMap.tsx b/frontend/src/components/weather/WeatherMap.tsx index deb449d..a1803d4 100644 --- a/frontend/src/components/weather/WeatherMap.tsx +++ b/frontend/src/components/weather/WeatherMap.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Spinner, Text, Box } from '@chakra-ui/react'; import { MapContainer, TileLayer, Marker, Popup, ScaleControl, ZoomControl } from 'react-leaflet'; @@ -34,12 +34,6 @@ interface ForecastWithLocation { location: { latitude: number; longitude: number }; } -interface WeatherMapProps { - forecastData?: ApiNestedResponse; - tempData?: ApiNestedResponse; - windData?: ApiNestedResponse; -} - const center: [number, number] = [1.3521, 103.8198]; const escapeHtml = (value: string) => @@ -97,7 +91,7 @@ const makeForecastMarkerHtml = ({ `; -const WeatherMap: React.FC = ({ forecastData, tempData, windData }) => { +const WeatherMap: React.FC<{ forecastData?: ApiNestedResponse; tempData?: ApiNestedResponse; windData?: ApiNestedResponse; }> = ({ forecastData, tempData, windData }) => { const { data: fetchedTempData, isLoading: tempLoading, error: tempError } = useQuery>({ queryKey: ['weather', 'air-temperature'], queryFn: () => WeatherService.getAirTemperature() as Promise>, @@ -133,42 +127,76 @@ const WeatherMap: React.FC = ({ forecastData, tempData, windDat return Error loading map data; } - const tempStations = actualTempData.data?.stations || []; - const tempReadings = actualTempData.data?.readings || []; - const windReadings = actualWindData.data?.readings || []; - const items = actualForecastData.data?.items || []; - const forecasts = items[0]?.forecasts || []; - const areaMetadata = actualForecastData.data?.area_metadata || []; + // Memoize the data processing to avoid recomputing on every render + const processedData = useMemo(() => { + const tempStations = actualTempData.data?.stations || []; + const tempReadings = actualTempData.data?.readings || []; + const windReadings = actualWindData.data?.readings || []; + const items = actualForecastData.data?.items || []; + const forecasts = items[0]?.forecasts || []; + const areaMetadata = actualForecastData.data?.area_metadata || []; - if (tempStations.length === 0 || tempReadings.length === 0) { + if (tempStations.length === 0 || tempReadings.length === 0) { + return null; + } + + const latestTempReading = tempReadings[0]; + const latestWindReading = windReadings[0]; + + if (!latestTempReading || !latestTempReading.data) { + return null; + } + + const latestReadingTime = formatTime(latestTempReading.timestamp); + const tempStationMap = new Map(tempStations.map((st: AirTempStation) => [st.id, st])); + const windDataMap = new Map(latestWindReading?.data?.map((rd: WindDataPoint) => [rd.stationId, rd.value]) || []); + + const getCardinalDirection = (deg: number | null | undefined) => { + if (deg === null || deg === undefined) return 'N/A'; + const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']; + const idx = Math.round((deg % 360) / 22.5) % 16; + return dirs[idx]; + }; + + const forecastsWithLocation = forecasts + .map(f => ({ + area: f.area, + forecast: f.forecast, + location: areaMetadata.find(a => a.name === f.area)?.label_location, + })) + .filter(f => f.location) as ForecastWithLocation[]; + + return { + tempStations, + tempReadings, + windReadings, + items, + forecasts, + areaMetadata, + latestTempReading, + latestWindReading, + latestReadingTime, + tempStationMap, + windDataMap, + getCardinalDirection, + forecastsWithLocation + }; + }, [actualTempData, actualWindData, actualForecastData]); + + if (!processedData) { return No map data available; } - const latestTempReading = tempReadings[0]; - const latestWindReading = windReadings[0]; - - if (!latestTempReading || !latestTempReading.data) { - return No map data available; - } - - const latestReadingTime = formatTime(latestTempReading.timestamp); - const tempStationMap = new Map(tempStations.map((st: AirTempStation) => [st.id, st])); - const windDataMap = new Map(latestWindReading?.data?.map((rd: WindDataPoint) => [rd.stationId, rd.value]) || []); - - const getCardinalDirection = (deg: number | null | undefined) => { - if (deg === null || deg === undefined) return 'N/A'; - const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']; - const idx = Math.round((deg % 360) / 22.5) % 16; - return dirs[idx]; - }; - - const forecastsWithLocation = forecasts - .map(f => ({ - area: f.area, - forecast: f.forecast, - location: areaMetadata.find(a => a.name === f.area)?.label_location, - })) - .filter(f => f.location) as ForecastWithLocation[]; + const { + tempStations, + latestTempReading, + latestWindReading, + latestReadingTime, + tempStationMap, + windDataMap, + getCardinalDirection, + forecastsWithLocation + } = processedData; return ( @@ -249,4 +277,4 @@ const WeatherMap: React.FC = ({ forecastData, tempData, windDat ); }; -export default WeatherMap; \ No newline at end of file +export default React.memo(WeatherMap); \ No newline at end of file diff --git a/frontend/src/routes/_layout/weather-hub.tsx b/frontend/src/routes/_layout/weather-hub.tsx index 52e55be..d64aba5 100644 --- a/frontend/src/routes/_layout/weather-hub.tsx +++ b/frontend/src/routes/_layout/weather-hub.tsx @@ -1,12 +1,14 @@ +import React from 'react'; import { Container, Box, Heading, Text, Separator, Tabs as ChakraTabs } from '@chakra-ui/react'; import { createFileRoute } from '@tanstack/react-router'; -import TwoHourForecast from '@/components/weather/TwoHourForecast'; -import AirTemperature from '@/components/weather/AirTemperature'; -import FourDayOutlook from '@/components/weather/FourDayOutlook'; -import WeatherStatistics from '@/components/weather/WeatherStatistics'; -import WindDirection from '@/components/weather/WindDirection'; -import WeatherMap from '@/components/weather/WeatherMap'; +// Lazy-load components to improve initial bundle size +const TwoHourForecast = React.lazy(() => import('@/components/weather/TwoHourForecast')); +const AirTemperature = React.lazy(() => import('@/components/weather/AirTemperature')); +const FourDayOutlook = React.lazy(() => import('@/components/weather/FourDayOutlook')); +const WeatherStatistics = React.lazy(() => import('@/components/weather/WeatherStatistics')); +const WindDirection = React.lazy(() => import('@/components/weather/WindDirection')); +const WeatherMap = React.lazy(() => import('@/components/weather/WeatherMap')); const tabValues = { twoHour: 'twoHourForecast', @@ -44,22 +46,34 @@ function WeatherHub() { - + Loading forecast...}> + + - + Loading temperature...}> + + - + Loading forecast...}> + + - + Loading statistics...}> + + - + Loading wind data...}> + + - + Loading map...}> + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b3db6da..3fd96d6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,7 +10,13 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, - plugins: [react(), TanStackRouterVite()], + plugins: [ + react({ + // Enable JSX runtime for React 19 + jsxRuntime: "automatic" + }), + TanStackRouterVite(), + ], server: { allowedHosts: [ "dev.lan", @@ -31,4 +37,16 @@ export default defineConfig({ }, }, }, + build: { + // Enable tree-shaking by default + rollupOptions: { + output: { + // Split vendor code into separate chunks for better caching + manualChunks: { + vendor: ["react", "react-dom", "@tanstack/react-query", "@chakra-ui/react"], + leaflet: ["leaflet", "react-leaflet"], + }, + }, + }, + }, })