From 08d9cb2a44e207b4640aa006668c9206094c6f17 Mon Sep 17 00:00:00 2001 From: Ronja Date: Tue, 10 Nov 2026 20:44:01 +0100 Subject: [PATCH] perf: split the JavaScript bundle per route Load every page except the Elements page with React.lazy, so the first file goes from 766.83 kB (242 kB gzip) to 522.21 kB (166 kB gzip). The Elements page stays in the first file: logging out navigates to it while a protected page is on the screen. The App tests wait for the lazy pages. Closes #176 --- README.md | 2 +- docs/AI_USAGE.md | 8 ++++++ docs/SUSTAINABILITY.md | 2 +- src/App.tsx | 59 ++++++++++++++++++++++++++---------------- src/tests/App.test.tsx | 41 +++++++++++++++++++---------- 5 files changed, 74 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 96b378f..0d56c96 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Other commands: The frontend uses [Apollo Client](https://www.apollographql.com/docs/react) (`src/lib/apollo/`), and `src/main.tsx` wraps the app in `ApolloProvider`. Start the backend (`npm run backend`, port 4000) and the frontend (`npm run dev`). In development Vite forwards `/project2/graphql` and `/project2/images` to the backend at `/graphql` and `/images`, so the browser sees one address and there is no CORS to set up. Set `VITE_API_URL` (for example in `.env.local`) to call another address; by default the API address is the app base path followed by `graphql`. Image paths are likewise resolved under the app base path. See "Connect the frontend to the backend" in [docs/VM_SETUP.md](docs/VM_SETUP.md). -**Bundle size.** A current `npm run build` reports a JavaScript bundle of about 548 kB (171 kB gzip), above Vite's 500 kB warning threshold. The catalog JSON was removed from the frontend when the detail page moved to the API; the historical before/after sizes and possible improvements are described in [docs/SUSTAINABILITY.md](docs/SUSTAINABILITY.md). +**Bundle size.** A current `npm run build` reports a first JavaScript file of about 522 kB (166 kB gzip), still a little above Vite's 500 kB warning threshold. Each page except the Elements page is its own file that is loaded when its route is opened (`React.lazy` in `src/App.tsx`), so the first visit does not download the gymnast pages, the element page or the login. The catalog JSON was removed from the frontend when the detail page moved to the API; the historical before/after sizes and possible improvements are described in [docs/SUSTAINABILITY.md](docs/SUSTAINABILITY.md). ```tsx import { gql } from '@apollo/client' diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 3e7719c..4b2a68e 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -589,3 +589,11 @@ - Changed outside the Gymnast page: the element page (colors, the badge, "Try in a routine", "Add to gymnast"), "Your gymnasts" (the wrapping of the title and the list), `variables.css` (`--card-hover`), and two shared parts that other pages use too: `Button` (the `icon` prop, which changes nothing when it is not given) and `DifficultyBadge` (its colors now follow the theme, also in the results of the Elements page). - Not done on purpose: moving the error messages of the other pages (the elements results, the element page, the login, "Your gymnasts") to the three layers, because those files belong to other pull requests and the change would conflict with them. The menus of the Elements page still have their own copies of the menu classes that `menuStyles.ts` now holds. - **Limits:** The end-to-end tests need the database and the backend, and are run by hand: the course git server has no runners. A screen reader was not used: that has to be done by a person. Lighthouse was started on a production build in the last round, but the run did not finish (the computer was low on memory), so there are no Lighthouse scores from that round. "Your gymnasts" was fixed after the last check in a browser and has not been measured again, and the results table of the Elements page, which uses the changed badge, has not been looked at in dark mode. The test gymnast was made with the test login in the local database and deleted again. + +## The JavaScript split per route (`src/App.tsx`) + +- **Tool:** Claude Code +- **What the team did:** Took the peer feedback that the bundle (about 767 kB) had no code splitting, and wrote issue #176. +- **What Claude did:** Made every page except the Elements page a `React.lazy` chunk with one `Suspense` and a status message as the fallback. The first file went from 766.83 kB (242 kB gzip) to 522.21 kB (166 kB gzip). Updated the five App tests that expected a page at once, so they wait for it, and the numbers in the README and `docs/SUSTAINABILITY.md`. +- **What was found:** A first version made the Elements page lazy too, and a test caught a real problem: logging out on the Gymnasts page navigates to `/elements`, which then had to download first, and until it did the protected page was still mounted and sent the visitor to the login page. The Elements page is where `/` leads, so it stays in the first file. The 57 end-to-end tests pass. +- **Limits:** The first file is still above Vite's 500 kB warning (the libraries are most of it). The gain on a real connection is not measured here (issue #157). diff --git a/docs/SUSTAINABILITY.md b/docs/SUSTAINABILITY.md index 3900b4c..2d7b5ac 100644 --- a/docs/SUSTAINABILITY.md +++ b/docs/SUSTAINABILITY.md @@ -49,7 +49,7 @@ The app offers a dark theme. On OLED displays, darker pixels can use less power ## What we have not measured and could improve - We have not measured energy use or carbon emissions per visit, real-world data transferred on a typical user journey, or the energy mix of the hosting environment. A next step would be to establish repeatable measurements for transferred bytes, load performance, and browser processing. -- The current JavaScript bundle is **548.06 kB uncompressed**, above Vite's 500 kB warning threshold. We could inspect the bundle and consider route-level code splitting as the application grows, while measuring whether that improves the experience. +- The JavaScript was one file of **766.83 kB (242.32 kB gzip)** and is now split per route (`React.lazy` in `src/App.tsx`): the first file is **522.21 kB (166.39 kB gzip)**, and the pages other than the Elements page (the largest are the Gymnast page at 31.8 kB and the element page at 18.2 kB) load when their route is opened. The Elements page stays in the first file because nearly every visit starts there. The first file is still a little above Vite's 500 kB warning threshold; what is left in it is mostly the libraries (React, React Router, Apollo Client, Base UI). Measured with `npm run build`; the effect on a real connection is measured in issue #157. - We have not measured the power difference between light and dark themes on OLED or other displays. - We should continue to review whether images, fonts, libraries, animation, and network requests are necessary, and prefer lighter alternatives when they meet the user need. diff --git a/src/App.tsx b/src/App.tsx index dd8dd65..62c2cd3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { lazy, Suspense, useEffect, useState } from 'react' import { Navigate, Route, Routes } from 'react-router-dom' import { Navbar } from './components/navbar/Navbar' import { SkipLink } from './components/skipLink/SkipLink' @@ -7,14 +7,18 @@ import { ProtectedRoute } from './components/ProtectedRoute' import { AuthProvider } from './components/auth/AuthProvider' // One page for each route (src/pages/). The parts of the pages are in src/components/. -import AboutPage from '@/pages/AboutPage' -import CalculatorPage from '@/pages/CalculatorPage' -import ElementDetailPage from '@/pages/ElementDetailPage' +// Each page is its own chunk, loaded when its route is opened, so the first visit only +// downloads the code of the page that is shown. The Elements page is not lazy: it is where +// "/" and the logo lead, so nearly every visit needs it, and logging out navigates to it +// while a protected page is still on the screen, which must not wait for a download. import ElementsPage from '@/pages/ElementsPage' -import GymnastPage from '@/pages/GymnastPage' -import LoginPage from '@/pages/LoginPage' -import NotFoundPage from '@/pages/NotFoundPage' -import YourGymnastsPage from '@/pages/YourGymnastsPage' +const AboutPage = lazy(() => import('@/pages/AboutPage')) +const CalculatorPage = lazy(() => import('@/pages/CalculatorPage')) +const ElementDetailPage = lazy(() => import('@/pages/ElementDetailPage')) +const GymnastPage = lazy(() => import('@/pages/GymnastPage')) +const LoginPage = lazy(() => import('@/pages/LoginPage')) +const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) +const YourGymnastsPage = lazy(() => import('@/pages/YourGymnastsPage')) import './index.css' import './styles/variables.css' @@ -43,23 +47,34 @@ export function App() { /> {/* The pages have their own
, so this is only the target of the skip link. */}
- - } /> - } /> - } /> + + Loading the page +

+ } + > + + } /> + } /> + } + /> - } /> - } /> - } /> + } /> + } /> + } /> - {/* Protected routes requiring login */} - }> - } /> - } /> - + {/* Protected routes requiring login */} + }> + } /> + } /> + - } /> - + } /> +
+
{/* The last thing on every page: a link back to the start, for the keyboard and screen readers. */} diff --git a/src/tests/App.test.tsx b/src/tests/App.test.tsx index eaa3906..ee08015 100644 --- a/src/tests/App.test.tsx +++ b/src/tests/App.test.tsx @@ -1,6 +1,12 @@ // @vitest-environment jsdom import { ApolloProvider } from '@apollo/client/react' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { createTestClient, ok, type Handler } from '@/tests/apollo' @@ -75,22 +81,23 @@ describe('App', () => { ).toBe(`${import.meta.env.BASE_URL}calculator`) }) - it('renders routed content inside a single main landmark', () => { + it('renders routed content inside a single main landmark', async () => { renderApp() - expect(screen.getAllByRole('main')).toHaveLength(1) + expect(await screen.findAllByRole('main')).toHaveLength(1) fireEvent.click(screen.getByRole('link', { name: 'About' })) - expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy() + expect(await screen.findByRole('heading', { name: 'About' })).toBeTruthy() expect(screen.getAllByRole('main')).toHaveLength(1) }) - it('ends every page with the link back to the top, which comes after the page', () => { + it('ends every page with the link back to the top, which comes after the page', async () => { renderApp() for (const name of ['Search', 'About', 'Calculator']) { fireEvent.click(screen.getByRole('link', { name })) + await screen.findByRole('main') const top = screen.getByRole('link', { name: 'Go to the top of the page', @@ -102,27 +109,33 @@ describe('App', () => { } }) - it('gives every page its own title, so the tab and the history say where you are', () => { + it('gives every page its own title, so the tab and the history say where you are', async () => { renderApp() - expect(document.title).toBe('Elements – WAG CoP Planner') + await waitFor(() => + expect(document.title).toBe('Elements – WAG CoP Planner'), + ) fireEvent.click(screen.getByRole('link', { name: 'About' })) - expect(document.title).toBe('About – WAG CoP Planner') + await waitFor(() => expect(document.title).toBe('About – WAG CoP Planner')) fireEvent.click(screen.getByRole('link', { name: 'Calculator' })) - expect(document.title).toBe('Calculator – WAG CoP Planner') + await waitFor(() => + expect(document.title).toBe('Calculator – WAG CoP Planner'), + ) window.history.pushState({}, '', `${import.meta.env.BASE_URL}no-such-page`) cleanup() renderApp() - expect(document.title).toBe('Page not found – WAG CoP Planner') + await waitFor(() => + expect(document.title).toBe('Page not found – WAG CoP Planner'), + ) }) - it('opens the search page from the start address, the logo and the Search link', () => { + it('opens the search page from the start address, the logo and the Search link', async () => { renderApp() expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`) expect( - screen.getByRole('heading', { level: 1, name: 'Elements' }), + await screen.findByRole('heading', { level: 1, name: 'Elements' }), ).toBeTruthy() for (const link of [ @@ -130,14 +143,14 @@ describe('App', () => { () => screen.getByRole('link', { name: 'Search' }), ]) { fireEvent.click(screen.getByRole('link', { name: 'About' })) - expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy() + expect(await screen.findByRole('heading', { name: 'About' })).toBeTruthy() fireEvent.click(link()) expect(window.location.pathname).toBe( `${import.meta.env.BASE_URL}elements`, ) expect( - screen.getByRole('heading', { level: 1, name: 'Elements' }), + await screen.findByRole('heading', { level: 1, name: 'Elements' }), ).toBeTruthy() } })