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
+42 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { generateSW } from '../../pwa.mjs';
@@ -44,4 +45,44 @@ describe(`test-build: ${nodeAdapter ? 'node' : 'static'} adapter`, () => {
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy();
}
});
const outputRoot = `./build/${nodeAdapter ? 'client/' : ''}`;
const nodesDir = './.svelte-kit/output/server/nodes/';
const gzippedSize = (path: string) => gzipSync(readFileSync(`${outputRoot}${path}`)).length;
/** The client files a route node makes the browser load (its imports and stylesheets). */
function assetsLoadedBy(node: string, extension: 'js' | 'css'): string[] {
const pattern = new RegExp(`_app/immutable/[^"']+\\.${extension}`, 'g');
return [...new Set(readFileSync(`${nodesDir}${node}`, 'utf-8').match(pattern) ?? [])];
}
it('ships the app stylesheet once, hashed and small', () => {
const referenced = new Set(
readdirSync(nodesDir).flatMap((node) => assetsLoadedBy(node, 'css'))
);
// Every page loads Tailwind's preflight; exactly one served stylesheet may contain it.
const withPreflight = [...referenced].filter((path) =>
readFileSync(`${outputRoot}${path}`, 'utf-8').includes('--tw-content')
);
expect(withPreflight, 'Tailwind is bundled more than once').toHaveLength(1);
// The un-hashed output.css exists only for offline.html; pages must not block on it.
const appHtml = readFileSync('./src/app.html', 'utf-8');
expect(appHtml).not.toMatch(/output\.css/);
});
// Regression budgets for what every page downloads before it can render: the root layout's
// scripts and stylesheets. Unlike Lighthouse timings these sizes don't vary between runs, so any
// growth past the budget fails the PR. Measured September 2026: 95.7 KB JS and 15.7 KB CSS
// gzipped. Raise a budget in the same PR only when the extra weight is deliberate.
it.each([
['js', 105 * 1024],
['css', 18 * 1024]
] as const)('keeps the layout %s loaded on every page within budget', (extension, budget) => {
const total = assetsLoadedBy('0.js', extension).reduce(
(sum, path) => sum + gzippedSize(path),
0
);
expect(total, `layout ${extension} is ${total} bytes gzipped`).toBeLessThan(budget);
});
});