From 6cea655d8832adc4418230254786a37389ecaf12 Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Mon, 8 Jun 2026 20:52:28 -0400 Subject: [PATCH 1/3] perf: speed up homepage and app initial load - Lazy-load Chessground in ErrorBoundary and PlaySetupModal via next/dynamic - Split barrel imports (src/contexts, src/components) to direct paths for better tree-shaking and code-splitting - Optimize Material Symbols font loading (preconnect/preload, swap, and hide icons until the font is ready to avoid layout flash) - Homepage component updates (GameCarousel, HomeHero, index) Co-Authored-By: Claude Opus 4.8 --- src/components/Common/ErrorBoundary.tsx | 9 ++- src/components/Common/FeedbackButton.tsx | 2 +- src/components/Common/PlaySetupModal.tsx | 8 +- src/components/Home/GameCarousel.tsx | 74 +++++++++++++++++-- src/components/Home/HomeHero.tsx | 41 ++++++++-- .../Sections/AdditionalFeaturesSection.tsx | 2 +- src/components/Home/Sections/PlaySection.tsx | 3 +- src/contexts/ModalContext.tsx | 19 +++-- src/pages/_app.tsx | 71 +++++++++++++----- src/pages/index.tsx | 67 ++++++++++++++--- src/styles/tailwind.css | 23 +++++- 11 files changed, 264 insertions(+), 55 deletions(-) diff --git a/src/components/Common/ErrorBoundary.tsx b/src/components/Common/ErrorBoundary.tsx index 2d83ccf3..bbc45909 100644 --- a/src/components/Common/ErrorBoundary.tsx +++ b/src/components/Common/ErrorBoundary.tsx @@ -1,11 +1,16 @@ import Link from 'next/link' import Head from 'next/head' +import dynamic from 'next/dynamic' import { Component } from 'react' -import Chessground from '@react-chess/chessground' import { Header } from './Header' import { Footer } from './Footer' import { trackErrorEncountered } from 'src/lib/analytics' +const ErrorChessground = dynamic(() => import('@react-chess/chessground'), { + ssr: false, + loading: () =>
, +}) + interface Props { children: React.ReactNode } @@ -109,7 +114,7 @@ export class ErrorBoundary extends Component {
- { const { user } = useContext(AuthContext) diff --git a/src/components/Common/PlaySetupModal.tsx b/src/components/Common/PlaySetupModal.tsx index 0d394594..c209f0f9 100644 --- a/src/components/Common/PlaySetupModal.tsx +++ b/src/components/Common/PlaySetupModal.tsx @@ -12,7 +12,7 @@ import { TimeControlOptionNames, TimeControlOptions, } from 'src/types' -import { ModalContext } from 'src/contexts' +import { ModalContext } from 'src/contexts/ModalContext' import { ModalContainer } from './ModalContainer' const maiaOptions = [ @@ -72,7 +72,7 @@ function OptionSelect({ ) } -interface Props { +export interface PlaySetupModalProps { playType: PlayType player?: Color timeControl?: TimeControl @@ -89,7 +89,9 @@ interface Props { modalSubtitle?: string } -export const PlaySetupModal: React.FC = (props: Props) => { +export const PlaySetupModal: React.FC = ( + props: PlaySetupModalProps, +) => { const { setPlaySetupModalProps } = useContext(ModalContext) const router = useRouter() const { push } = router diff --git a/src/components/Home/GameCarousel.tsx b/src/components/Home/GameCarousel.tsx index 09968885..e115b688 100644 --- a/src/components/Home/GameCarousel.tsx +++ b/src/components/Home/GameCarousel.tsx @@ -181,7 +181,10 @@ export const GameCarousel: React.FC = () => { const router = useRouter() const [games, setGames] = useState(SAMPLE_GAMES) const [isPaused, setIsPaused] = useState(false) + const [isCarouselVisible, setIsCarouselVisible] = useState(false) + const [shouldLoadLiveGames, setShouldLoadLiveGames] = useState(false) const abortController = useRef(null) + const sectionRef = useRef(null) const carouselRef = useRef(null) const handleGameStart = useCallback((gameData: StreamedGame) => { @@ -300,7 +303,7 @@ export const GameCarousel: React.FC = () => { }, []) useEffect(() => { - if (isPaused || !carouselRef.current) return + if (isPaused || !isCarouselVisible || !carouselRef.current) return const scroll = () => { if (carouselRef.current) { @@ -317,18 +320,74 @@ export const GameCarousel: React.FC = () => { const interval = setInterval(scroll, 20) return () => clearInterval(interval) - }, [isPaused]) + }, [isPaused, isCarouselVisible]) useEffect(() => { - fetchNewGame() - fetchBroadcast() + const section = sectionRef.current + + if (!section || typeof IntersectionObserver === 'undefined') { + setIsCarouselVisible(true) + setShouldLoadLiveGames(true) + return + } + + const observer = new IntersectionObserver( + ([entry]) => { + setIsCarouselVisible(entry.isIntersecting) + + if (entry.isIntersecting) { + setShouldLoadLiveGames(true) + } + }, + { rootMargin: '300px 0px' }, + ) + + observer.observe(section) + + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!shouldLoadLiveGames) return + + const startLiveFeeds = () => { + fetchNewGame() + fetchBroadcast() + } + + const windowWithIdleCallback = window as Window & { + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions, + ) => number + cancelIdleCallback?: (handle: number) => void + } + + if (windowWithIdleCallback.requestIdleCallback) { + const idleHandle = windowWithIdleCallback.requestIdleCallback( + startLiveFeeds, + { + timeout: 2500, + }, + ) + + return () => { + windowWithIdleCallback.cancelIdleCallback?.(idleHandle) + if (abortController.current) { + abortController.current.abort() + } + } + } + + const timeoutId = window.setTimeout(startLiveFeeds, 1000) return () => { + window.clearTimeout(timeoutId) if (abortController.current) { abortController.current.abort() } } - }, [fetchNewGame, fetchBroadcast]) + }, [fetchNewGame, fetchBroadcast, shouldLoadLiveGames]) const handleGameClick = useCallback( (game: GameData) => { @@ -340,7 +399,10 @@ export const GameCarousel: React.FC = () => { ) return ( -
+
= ({ scrollHandler }: Props) => { // Fetch global stats and set up periodic updates useEffect(() => { const fetchGlobalStats = async () => { - const data = await getGlobalStats() - setGlobalStats(data) + try { + const data = await getGlobalStats() + setGlobalStats(data) + } catch (error) { + console.error('Error fetching global stats:', error) + } + } + + const windowWithIdleCallback = window as Window & { + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions, + ) => number + cancelIdleCallback?: (handle: number) => void } - // Fetch immediately - fetchGlobalStats() + const idleHandle = windowWithIdleCallback.requestIdleCallback?.( + fetchGlobalStats, + { + timeout: 2000, + }, + ) + const timeoutId = + idleHandle === undefined + ? window.setTimeout(fetchGlobalStats, 800) + : undefined // Update every 5 minutes const interval = setInterval(fetchGlobalStats, 5 * 60 * 1000) - return () => clearInterval(interval) + return () => { + if (idleHandle !== undefined) { + windowWithIdleCallback.cancelIdleCallback?.(idleHandle) + } + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId) + } + clearInterval(interval) + } }, []) return ( diff --git a/src/components/Home/Sections/AdditionalFeaturesSection.tsx b/src/components/Home/Sections/AdditionalFeaturesSection.tsx index 4c0b6526..0aa4e532 100644 --- a/src/components/Home/Sections/AdditionalFeaturesSection.tsx +++ b/src/components/Home/Sections/AdditionalFeaturesSection.tsx @@ -3,7 +3,7 @@ import { useContext, ReactNode } from 'react' import { motion } from 'framer-motion' import { useInView } from 'react-intersection-observer' -import { ModalContext } from 'src/contexts' +import { ModalContext } from 'src/contexts/ModalContext' import { PlayType } from 'src/types' import { StarIcon, BrainIcon, BotOrNotIcon } from 'src/components/Common/Icons' diff --git a/src/components/Home/Sections/PlaySection.tsx b/src/components/Home/Sections/PlaySection.tsx index cb751c57..1f769d04 100644 --- a/src/components/Home/Sections/PlaySection.tsx +++ b/src/components/Home/Sections/PlaySection.tsx @@ -6,7 +6,8 @@ import type { DrawShape } from 'chessground/draw' import Chessground from '@react-chess/chessground' import { useInView } from 'react-intersection-observer' import { useContext, useState, useEffect } from 'react' -import { ModalContext, AuthContext } from 'src/contexts' +import { AuthContext } from 'src/contexts/AuthContext' +import { ModalContext } from 'src/contexts/ModalContext' const DEMO_POSITION = { fen: 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R b KQkq - 0 1', diff --git a/src/contexts/ModalContext.tsx b/src/contexts/ModalContext.tsx index 4e910297..856c7b62 100644 --- a/src/contexts/ModalContext.tsx +++ b/src/contexts/ModalContext.tsx @@ -1,13 +1,22 @@ -import { PlaySetupModal } from 'src/components' -import React, { ComponentProps, ReactNode, useState } from 'react' +import dynamic from 'next/dynamic' +import React, { ReactNode, useState } from 'react' +import type { PlaySetupModalProps } from 'src/components/Common/PlaySetupModal' + +const PlaySetupModal = dynamic( + () => + import('src/components/Common/PlaySetupModal').then( + (mod) => mod.PlaySetupModal, + ), + { ssr: false }, +) const fn = () => { throw new Error('poorly provided ModalContext') } interface IModalContext { - playSetupModalProps?: ComponentProps - setPlaySetupModalProps: (arg0?: ComponentProps) => void + playSetupModalProps?: PlaySetupModalProps + setPlaySetupModalProps: (arg0?: PlaySetupModalProps) => void } export const ModalContext = React.createContext({ @@ -20,7 +29,7 @@ export const ModalContextProvider: React.FC<{ children: ReactNode }> = ({ children: ReactNode }) => { const [playSetupModalProps, setPlaySetupModalProps] = useState< - ComponentProps | undefined + PlaySetupModalProps | undefined >(undefined) return ( diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 6cd2139e..6e9a76d1 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,33 +9,31 @@ import { Analytics } from '@vercel/analytics/react' import { PostHogProvider } from 'posthog-js/react' import { SoundProvider } from 'src/contexts/SoundContext' -import { - AuthContextProvider, - ModalContextProvider, - WindowSizeContextProvider, - AnalysisListContextProvider, - MaiaEngineContextProvider, - StockfishEngineContextProvider, - SettingsProvider, - TourProvider as TourContextProvider, -} from 'src/contexts' +import { AnalysisListContextProvider } from 'src/contexts/AnalysisListContext' +import { AuthContextProvider } from 'src/contexts/AuthContext' +import { MaiaEngineContextProvider } from 'src/contexts/MaiaEngineContext' +import { ModalContextProvider } from 'src/contexts/ModalContext' +import { SettingsProvider } from 'src/contexts/SettingsContext' +import { StockfishEngineContextProvider } from 'src/contexts/StockfishEngineContext' +import { TourProvider as TourContextProvider } from 'src/contexts/TourContext' +import { WindowSizeContextProvider } from 'src/contexts/WindowSizeContext' import 'src/styles/tailwind.css' import 'src/styles/themes.css' import 'react-tooltip/dist/react-tooltip.css' import 'node_modules/chessground/assets/chessground.base.css' import 'node_modules/chessground/assets/chessground.brown.css' import 'node_modules/chessground/assets/chessground.cburnett.css' -import { - Footer, - Compose, - ErrorBoundary, - Header, - FeedbackButton, -} from 'src/components' +import { Compose } from 'src/components/Common/Compose' +import { ErrorBoundary } from 'src/components/Common/ErrorBoundary' +import { FeedbackButton } from 'src/components/Common/FeedbackButton' +import { Footer } from 'src/components/Common/Footer' +import { Header } from 'src/components/Common/Header' import { browserPostHogConfig } from 'src/lib/posthog-browser-config' const openSansClassName = 'font-sans' const OG_IMAGE_URL = 'https://www.maiachess.com/assets/og-maia.png' +const MATERIAL_SYMBOLS_STYLESHEET = + 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap' function MaiaPlatform({ Component, pageProps }: AppProps) { const router = useRouter() @@ -64,6 +62,28 @@ function MaiaPlatform({ Component, pageProps }: AppProps) { }) }, []) + useEffect(() => { + const root = document.documentElement + const markMaterialSymbolsReady = () => { + root.classList.add('material-symbols-ready') + } + + if (!('fonts' in document)) { + markMaterialSymbolsReady() + return + } + + if (document.fonts.check('24px "Material Symbols Outlined"')) { + markMaterialSymbolsReady() + return + } + + document.fonts + .load('24px "Material Symbols Outlined"') + .then(markMaterialSymbolsReady) + .catch(() => undefined) + }, []) + return ( @@ -82,9 +102,22 @@ function MaiaPlatform({ Component, pageProps }: AppProps) { > + + + + +