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
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Pokedex } from '$lib/models/Pokedex';
const findCombinedData = vi.fn();
const countCombinedData = vi.fn();
const constructed: unknown[][] = [];
vi.mock('$lib/repositories/CombinedDataRepository', () => ({
default: class {
constructor(...args: unknown[]) {
constructed.push(args);
}
findCombinedData = findCombinedData;
countCombinedData = countCombinedData;
}
}));
vi.mock('$lib/services/PokedexDexScopeService', () => ({
resolveDexScopes: vi.fn(async () => ['national'])
}));
const { loadCombinedDataPage } = await import('$lib/services/CombinedDataService');
const supabase = {} as never;
const pokedex = { _id: 'dex-1', gameScope: 'Black' } as unknown as Pokedex;
describe('loadCombinedDataPage', () => {
beforeEach(() => {
constructed.length = 0;
findCombinedData.mockReset().mockResolvedValue([{ id: 'row' }]);
countCombinedData.mockReset().mockResolvedValue(45);
});
it("defaults to the Pokédex's game scope and reports pagination", async () => {
const result = await loadCombinedDataPage(supabase, 'user-1', pokedex, {
page: 2,
limit: 20,
enableForms: true
});
expect(constructed).toEqual([[supabase, 'user-1', 'dex-1']]);
expect(findCombinedData).toHaveBeenCalledWith('user-1', 2, 20, true, '', 'Black', ['national']);
expect(countCombinedData).toHaveBeenCalledWith(true, '', 'Black', ['national']);
expect(result).toEqual({
combinedData: [{ id: 'row' }],
totalPages: 3,
currentPage: 2,
totalCount: 45
});
});
it('prefers an explicit game and region filter', async () => {
await loadCombinedDataPage(supabase, 'user-1', pokedex, {
page: 1,
limit: 9999,
enableForms: false,
region: 'unova',
game: 'White'
});
expect(countCombinedData).toHaveBeenCalledWith(false, 'unova', 'White', ['national']);
});
it('runs the rows and count queries at the same time', async () => {
let releaseRows: (rows: unknown[]) => void = () => {};
findCombinedData.mockReturnValue(new Promise((resolve) => (releaseRows = resolve)));
const pending = loadCombinedDataPage(supabase, 'user-1', pokedex, {
page: 1,
limit: 10,
enableForms: false
});
// The count starts before the rows query has finished.
await vi.waitFor(() => expect(countCombinedData).toHaveBeenCalled());
releaseRows([]);
await expect(pending).resolves.toMatchObject({ totalCount: 45, totalPages: 5 });
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import { brotliDecompressSync, gunzipSync } from 'node:zlib';
import { compressResponse, pickEncoding } from '$lib/server/compression';
const html = '<!doctype html><p>' + 'Living Dex '.repeat(500) + '</p>';
function request(acceptEncoding?: string, method = 'GET') {
return new Request('http://localhost/', {
method,
headers: acceptEncoding ? { 'accept-encoding': acceptEncoding } : {}
});
}
function page(body: BodyInit | null = html, init: ResponseInit = {}) {
return new Response(body, {
status: 200,
headers: { 'content-type': 'text/html; charset=utf-8', 'content-length': '999' },
...init
});
}
async function bytes(response: Response) {
return Buffer.from(await response.arrayBuffer());
}
describe('pickEncoding', () => {
it.each([
['gzip, deflate, br', 'br'],
['gzip', 'gzip'],
['br;q=0, gzip', 'gzip'],
['*', 'br'],
['identity', null],
['gzip;q=0', null]
])('%s -> %s', (header, expected) => {
expect(pickEncoding(header)).toBe(expected);
});
it('returns null when the client sends no Accept-Encoding', () => {
expect(pickEncoding(null)).toBeNull();
});
});
describe('compressResponse', () => {
it('brotli-compresses HTML and the body round-trips', async () => {
const response = compressResponse(request('gzip, br'), page());
expect(response.headers.get('content-encoding')).toBe('br');
expect(response.headers.get('content-length')).toBeNull();
expect(response.headers.get('vary')).toMatch(/Accept-Encoding/);
const body = await bytes(response);
expect(body.length).toBeLessThan(html.length / 4);
expect(brotliDecompressSync(body).toString()).toBe(html);
});
it('falls back to gzip for JSON', async () => {
const json = JSON.stringify({ rows: Array.from({ length: 200 }, (_, i) => ({ i })) });
const response = compressResponse(
request('gzip'),
new Response(json, { headers: { 'content-type': 'application/json' } })
);
expect(response.headers.get('content-encoding')).toBe('gzip');
expect(gunzipSync(await bytes(response)).toString()).toBe(json);
});
it('keeps set-cookie headers and the status', async () => {
const original = page(html, { status: 404 });
original.headers.append('set-cookie', 'a=1; Path=/');
original.headers.append('set-cookie', 'b=2; Path=/');
const response = compressResponse(request('br'), original);
expect(response.status).toBe(404);
expect(response.headers.getSetCookie()).toEqual(['a=1; Path=/', 'b=2; Path=/']);
});
it('flushes each streamed chunk rather than buffering the whole body', async () => {
let sendRest: () => void = () => {};
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('<p>shell</p>'));
sendRest = () => {
controller.enqueue(new TextEncoder().encode('<p>streamed data</p>'));
controller.close();
};
}
});
const response = compressResponse(request('gzip'), page(stream));
const reader = response.body!.getReader();
// The shell arrives while the stream is still open.
const first = await reader.read();
expect(gunzipSync(Buffer.from(first.value!), { finishFlush: 2 }).toString()).toBe(
'<p>shell</p>'
);
sendRest();
const rest: Uint8Array[] = [Buffer.from(first.value!)];
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
rest.push(chunk.value);
}
expect(gunzipSync(Buffer.concat(rest)).toString()).toBe('<p>shell</p><p>streamed data</p>');
});
it('passes responses through inside a Netlify (Lambda) function', () => {
// adapter-netlify reads text bodies with response.text(), which would corrupt compressed bytes.
process.env.AWS_LAMBDA_FUNCTION_NAME = 'sveltekit-render';
try {
const original = page();
expect(compressResponse(request('br'), original)).toBe(original);
} finally {
delete process.env.AWS_LAMBDA_FUNCTION_NAME;
}
});
it.each([
['a client that accepts no encoding', request(), page()],
['a HEAD request', request('br', 'HEAD'), page(null)],
['an image', request('br'), new Response('png', { headers: { 'content-type': 'image/png' } })],
['a 304', request('br'), new Response(null, { status: 304 })],
[
'an already-encoded body',
request('br'),
new Response('x', { headers: { 'content-type': 'text/html', 'content-encoding': 'gzip' } })
]
])('leaves %s uncompressed', async (_label, req, res) => {
const response = compressResponse(req, res);
expect(response.headers.get('content-encoding')).toBe(res.headers.get('content-encoding'));
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
const getUser = vi.fn();
const getSession = vi.fn();
vi.mock('$env/static/public', () => ({
PUBLIC_SUPABASE_URL: 'http://127.0.0.1:54321',
PUBLIC_SUPABASE_ANON_KEY: 'anon'
}));
vi.mock('@supabase/ssr', () => ({
createServerClient: () => ({ auth: { getUser, getSession } })
}));
const { handle } = await import('../../src/hooks.server');
async function runHandle() {
const event = {
cookies: { getAll: () => [], set: vi.fn() },
locals: {}
} as unknown as Parameters<typeof handle>[0]['event'];
await handle({ event, resolve: vi.fn(async () => new Response()) } as never);
return event.locals;
}
describe('safeGetSession', () => {
it('validates the session with Supabase Auth only once per request', async () => {
getUser.mockReset().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null });
getSession.mockReset().mockResolvedValue({ data: { session: { access_token: 't' } } });
const locals = await runHandle();
const [first, second] = await Promise.all([locals.safeGetSession(), locals.safeGetSession()]);
const third = await locals.safeGetSession();
expect(getUser).toHaveBeenCalledTimes(1);
expect(first.user?.id).toBe('user-1');
expect(second).toBe(first);
expect(third).toBe(first);
});
it('does not share a session between requests', async () => {
getUser.mockReset().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null });
getSession.mockReset().mockResolvedValue({ data: { session: {} } });
await (await runHandle()).safeGetSession();
await (await runHandle()).safeGetSession();
expect(getUser).toHaveBeenCalledTimes(2);
});
it('returns no session when the JWT is rejected', async () => {
getUser.mockReset().mockResolvedValue({ data: { user: null }, error: new Error('bad jwt') });
getSession.mockReset();
const locals = await runHandle();
await expect(locals.safeGetSession()).resolves.toEqual({ session: null, user: null });
expect(getSession).not.toHaveBeenCalled();
});
});