diff --git a/README.md b/README.md index 631877e..4c3808e 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,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 44e324f..7f87dd9 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -590,6 +590,14 @@ - 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). + ## A shorter README (`README.md`, `docs/`) - **Tool:** Claude Code 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 791a6a8..3db5465 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' @@ -46,23 +50,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 8e30fe8..9904356 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, vi } from 'vitest' import { createTestClient, ok, type Handler } from '@/tests/apollo' @@ -90,22 +96,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', @@ -117,27 +124,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 [ @@ -145,14 +158,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() } })