perf: fix slow page loads and gate performance in CI

- Ship one hashed Tailwind stylesheet instead of two (one render-blocking)
- Compress responses in-app (brotli/gzip, streaming-safe) and precompress
  the node build so local and CI measurements match production
- Validate the Supabase session once per request
- Render the homepage immediately and stream public stats
- Stream a Pokedex's entries with the page instead of fetching after
  hydration, running the rows and count queries in parallel
- Shrink the avatar and offline placeholder images, fix layout shift,
  contrast, link names and missing meta descriptions
- Add Lighthouse CI (mobile + desktop) with score and metric budgets,
  bundle-size budgets in the build tests, and signed-in speed scenarios
This commit is contained in:
Josh Creek
2026-09-14 20:53:53 +01:00
parent 556f120f16
commit ff29095c47
30 changed files with 4166 additions and 487 deletions
+19
View File
@@ -0,0 +1,19 @@
Feature: Signed-in page speed
As a trainer
I want my Pokédexes to open and switch quickly
So that tracking catches never feels sluggish
Lighthouse CI covers the public pages; these budgets cover the signed-in ones it can't reach.
Background:
Given I am signed in
And I have a Living Dex named "Speed Check"
Scenario: A Pokédex opens without a second round trip for its entries
When I load the Pokédex page directly
Then its entries appear within 5 seconds
And the browser did not request the entries separately
Scenario: Moving between my Pokédex list and a Pokédex is quick
When I switch between my Pokédex list and the Pokédex
Then each switch finishes within 3 seconds
+77
View File
@@ -0,0 +1,77 @@
import { createBdd } from 'playwright-bdd';
import type { Page } from '@playwright/test';
import { test, expect } from '../fixtures';
const { When, Then } = createBdd(test);
// Per-page scratch values; scenarios run one at a time (workers: 1).
const timings = new WeakMap<
Page,
{ entriesMs?: number; switchMs: number[]; entryRequests: number }
>();
function entriesVisible(page: Page) {
return expect(page.getByRole('button', { name: /^View details for / }).first()).toBeVisible({
timeout: 30_000
});
}
When('I load the Pokédex page directly', async ({ page, state }) => {
if (!state.pokedexId) throw new Error('A Pokédex must exist before it can be opened');
const record = { switchMs: [], entryRequests: 0 };
timings.set(page, record);
// Only requests made while the page first loads matter; the page's 60s reconciliation refetch
// can't fire within this window.
const countEntryRequests = (request: { url(): string }) => {
if (/\/api\/pokedexes\/[^/]+\/combined-data/.test(request.url())) record.entryRequests++;
};
page.on('request', countEntryRequests);
const started = Date.now();
await page.goto(`/pokedex/${state.pokedexId}`);
await entriesVisible(page);
(record as { entriesMs?: number }).entriesMs = Date.now() - started;
page.off('request', countEntryRequests);
});
Then('its entries appear within {int} seconds', async ({ page }, seconds: number) => {
const entriesMs = timings.get(page)?.entriesMs;
expect(entriesMs, 'entries never became visible').toBeDefined();
expect(entriesMs!).toBeLessThan(seconds * 1000);
});
Then('the browser did not request the entries separately', async ({ page }) => {
// The server load streams the first page of entries with the HTML, so the page must not make
// the old hydrate-then-fetch round trip.
expect(timings.get(page)?.entryRequests).toBe(0);
});
When('I switch between my Pokédex list and the Pokédex', async ({ page, state }) => {
if (!state.pokedexName) throw new Error('A Pokédex must exist before switching to it');
const record = { switchMs: [] as number[], entryRequests: 0 };
timings.set(page, record);
await page.goto('/my-pokedexes');
const card = page.locator('.card').filter({ hasText: state.pokedexName }).first();
await expect(card).toBeVisible();
for (let round = 0; round < 2; round++) {
// List -> Pokédex: a client-side navigation through the card's View button.
let started = Date.now();
await card.getByRole('button', { name: 'View', exact: true }).click();
await page.waitForURL('**/pokedex/**');
await entriesVisible(page);
record.switchMs.push(Date.now() - started);
// Pokédex -> list: back navigation is also handled by the client router.
started = Date.now();
await page.goBack();
await expect(card).toBeVisible();
record.switchMs.push(Date.now() - started);
}
});
Then('each switch finishes within {int} seconds', async ({ page }, seconds: number) => {
const switchMs = timings.get(page)?.switchMs ?? [];
expect(switchMs).toHaveLength(4);
for (const ms of switchMs) expect(ms).toBeLessThan(seconds * 1000);
});