perf(pokedex): send the box grid as packed rows with the page

The server load fetched a full combined-data page at a 9999 item page size,
carrying detail text and ownership fields the grid never renders, and the client
re-fetched the same payload after hydration.

Load a trimmed grid row instead and pack it as positional tuples so field names
are not repeated for every one of a thousand-plus entries. Entry detail is
fetched on demand from the new per-entry endpoint when a cell is opened, and the
grid marks itself interactive so other page-start work can queue behind it.
This commit is contained in:
Josh Creek
2026-09-15 17:46:19 +01:00
parent 4c268ba15c
commit acaf760b36
16 changed files with 1382 additions and 444 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ Feature: Signed-in page speed
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
And the browser did not request the grid 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
+4 -3
View File
@@ -23,7 +23,8 @@ When('I load the Pokédex page directly', async ({ page, state }) => {
// 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++;
if (/\/api\/pokedexes\/[^/]+\/(?:grid|combined-data)/.test(request.url()))
record.entryRequests++;
};
page.on('request', countEntryRequests);
@@ -40,8 +41,8 @@ Then('its entries appear within {int} seconds', async ({ page }, seconds: number
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
Then('the browser did not request the grid separately', async ({ page }) => {
// The server load includes the compact grid with the HTML, so the page must not make
// the old hydrate-then-fetch round trip.
expect(timings.get(page)?.entryRequests).toBe(0);
});
@@ -0,0 +1,132 @@
import { packGrid, unpackGrid } from '$lib/models/PokedexGridRow';
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import { beforeAll, afterAll, describe, expect, it } from 'vitest';
import { requireLoopbackUrl } from '../support/loopback';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import CatchRecordRepository from '$lib/repositories/CatchRecordRepository';
import { loadPokedexGrid, loadPokedexEntryDetail } from '$lib/services/PokedexGridService';
import type { Pokedex } from '$lib/models/Pokedex';
const url = requireLoopbackUrl(
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
'TEST_SUPABASE_URL'
);
const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
const anonKey = process.env.TEST_SUPABASE_ANON_KEY;
describe('compact Pokédex and partial catch writes', () => {
let admin: SupabaseClient;
let client: SupabaseClient;
let owner = '';
let national: Pokedex;
let scoped: Pokedex;
let firstId: string;
let secondId: string;
beforeAll(async () => {
if (!serviceKey || !anonKey) throw new Error('Use npm run test:integration');
admin = createClient(url, serviceKey, { auth: { persistSession: false } });
const email = `grid-${crypto.randomUUID()}@example.test`;
const password = crypto.randomUUID();
const created = await admin.auth.admin.createUser({ email, password, email_confirm: true });
if (created.error) throw created.error;
owner = created.data.user.id;
client = createClient(url, anonKey, { auth: { persistSession: false } });
const signed = await client.auth.signInWithPassword({ email, password });
if (signed.error) throw signed.error;
const repo = new PokedexRepository(client, owner);
national = await repo.create({ name: 'Grid national', isFormDex: false });
scoped = await repo.create({ name: 'Grid forms', isFormDex: true, gameScope: 'Scarlet' });
const links = await client
.from('pokedex_dex_scopes')
.insert({ pokedexId: scoped._id, dexId: 'scarlet-paldea' });
if (links.error) throw links.error;
scoped = (await repo.findById(scoped._id))!;
const grid = await loadPokedexGrid(client, owner, national);
[firstId, secondId] = grid.slice(0, 2).map((row) => row.pokedexEntry._id);
});
afterAll(async () => {
if (owner) await admin.auth.admin.deleteUser(owner);
});
it('loads saved scopes with ownership and prevents cross-account reads', async () => {
expect(scoped.dexScopes).toEqual(['scarlet-paldea']);
const other = new PokedexRepository(client, crypto.randomUUID());
expect(await other.findById(scoped._id)).toBeNull();
});
it('matches full ordering and statuses while reducing serialized rows by at least 60%', async () => {
for (const dex of [national, scoped]) {
const initial = await loadPokedexGrid(client, owner, dex);
const catches = new CatchRecordRepository(client, owner, dex._id);
for (let offset = 0; offset < initial.length - 1; offset += 500)
await catches.bulkUpsert(
initial.slice(offset, Math.min(offset + 500, initial.length - 1)).map((row, index) => ({
pokemonId: row.pokedexEntry._id,
caught: index % 3 === 0,
inHome: index % 7 === 0,
personalNotes: ''
}))
);
const grid = await loadPokedexGrid(client, owner, dex);
const full = await new CombinedDataRepository(client, owner, dex._id).findAllCombinedData(
owner,
dex.isFormDex,
'',
dex.gameScope || '',
dex.dexScopes
);
expect(grid.map((row) => row.pokedexEntry._id)).toEqual(
full.map((row) => row.pokedexEntry._id)
);
expect(grid.length).toBeGreaterThan(400);
expect(unpackGrid(packGrid(grid))).toEqual(grid);
expect(JSON.stringify(packGrid(grid)).length).toBeLessThan(JSON.stringify(full).length * 0.4);
expect(JSON.stringify(grid)).not.toContain('personalNotes');
expect(JSON.stringify(grid)).not.toContain('catchInformation');
}
});
it('preserves omitted notes and statuses in mixed partial bulk writes, including new records', async () => {
const repo = new CatchRecordRepository(client, owner, national._id);
await repo.bulkUpsert([
{ pokemonId: firstId, personalNotes: 'Keep this note', caught: true, inHome: true }
]);
await repo.bulkUpsert([
{ pokemonId: firstId, haveToEvolve: true, caught: false },
{ pokemonId: secondId, personalNotes: 'New record' }
]);
expect(await repo.findByUserAndPokemon(owner, firstId, national._id)).toMatchObject({
personalNotes: 'Keep this note',
caught: false,
haveToEvolve: true,
inHome: true
});
expect(await repo.findByUserAndPokemon(owner, secondId, national._id)).toMatchObject({
personalNotes: 'New record',
caught: false
});
await repo.bulkUpsert([{ pokemonId: firstId, personalNotes: '' }]);
expect(await repo.findByUserAndPokemon(owner, firstId, national._id)).toMatchObject({
personalNotes: '',
inHome: true,
haveToEvolve: true
});
});
it('returns full details only for members of this dex', async () => {
const detail = await loadPokedexEntryDetail(client, owner, national, Number(firstId));
expect(detail?.pokedexEntry).toHaveProperty('catchInformation');
expect(detail?.catchRecord).toHaveProperty('personalNotes');
expect(await loadPokedexEntryDetail(client, owner, national, 999999)).toBeNull();
const forms = await loadPokedexGrid(client, owner, scoped);
const base = new Set(
(await loadPokedexGrid(client, owner, national)).map((row) => row.pokedexEntry._id)
);
const formOnly = forms.find((row) => !base.has(row.pokedexEntry._id));
expect(formOnly).toBeDefined();
expect(
await loadPokedexEntryDetail(client, owner, national, Number(formOnly!.pokedexEntry._id))
).toBeNull();
});
});
+122 -3
View File
@@ -11,7 +11,9 @@ type TableQuery = { table: string; calls: Call[] };
* `await query` / `await query.range(...)` resolve like a real PostgREST response. Returning
* an empty data set keeps the repository's paging loops to a single iteration.
*/
function createSupabaseStub() {
function createSupabaseStub(
resultFor: (table: string) => unknown = () => ({ data: [], error: null, count: 0 })
) {
const queries: TableQuery[] = [];
const from = (table: string) => {
@@ -23,8 +25,7 @@ function createSupabaseStub() {
{
get(_target, prop: string) {
if (prop === 'then') {
return (resolve: (value: unknown) => unknown) =>
resolve({ data: [], error: null, count: 0 });
return (resolve: (value: unknown) => unknown) => resolve(resultFor(table));
}
return (...args: unknown[]) => {
record.calls.push({ method: prop, args });
@@ -115,3 +116,121 @@ describe('CombinedDataRepository base-form filtering', () => {
expect(mentionsIsDefaultForm(supplement)).toBe(false);
});
});
describe('compact grid reads', () => {
const entry = {
id: 1,
pokedexNumber: 1,
pokemon: 'Bulbasaur',
form: null,
spriteKey: '1',
canGigantamax: false
};
it('joins catch flags by ID and retains entries without catches', async () => {
const { supabase } = createSupabaseStub((table) => ({
data:
table === 'catch_records'
? [{ id: 'catch', pokemonId: 1, caught: true, personalNotes: 'private' }]
: [entry, { ...entry, id: 2 }],
error: null
}));
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
const rows = await repo.joinGridCatches(await repo.findGridEntries(false, '', []));
expect(rows[0].catchRecord).toMatchObject({ _id: 'catch', caught: true });
expect(rows[0].catchRecord).not.toHaveProperty('personalNotes');
expect(rows[1].catchRecord).toBeNull();
});
it('deduplicates overlapping scopes without dropping named form supplements', async () => {
const base = { ...entry, pokemon: 'Rotom', form: 'Lightbulb', dexNumber: 1, dexSortOrder: 1 };
const { supabase } = createSupabaseStub((table) => ({
data:
table === 'game_pokedex_entry_details' ? [base, base] : [{ ...base, id: 2, form: 'Heat' }],
error: null
}));
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
const rows = await repo.findGridEntries(true, 'Black', ['one', 'two']);
expect(rows.map((row) => [row.id, row.form])).toEqual([
[1, 'Lightbulb'],
[2, 'Heat']
]);
});
it.each([[[]], [['scope']]])(
'reports entry query failure instead of an empty grid (%j)',
async (scopes) => {
const { supabase } = createSupabaseStub(() => ({
data: null,
error: { message: 'unavailable' }
}));
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
await expect(repo.findGridEntries(false, '', scopes)).rejects.toThrow('Unable to load');
}
);
it('reports catch failure instead of displaying everything as uncaught', async () => {
const { supabase } = createSupabaseStub((table) =>
table === 'catch_records'
? { data: null, error: { message: 'unavailable' } }
: { data: [entry], error: null }
);
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
await expect(repo.joinGridCatches(await repo.findGridEntries(false, '', []))).rejects.toThrow(
'Unable to load catch records'
);
});
it('keeps a failed detail read distinct from a missing entry', async () => {
const missing = createSupabaseStub(() => ({ data: null, error: null }));
expect(
await new CombinedDataRepository(missing.supabase, 'owner', 'dex').findEntryDetail(1)
).toBeNull();
const failed = createSupabaseStub(() => ({ data: null, error: { message: 'unavailable' } }));
await expect(
new CombinedDataRepository(failed.supabase, 'owner', 'dex').findEntryDetail(1)
).rejects.toThrow('Unable to load entry details');
});
it.each([false, true])(
'returns full instructions with optional catch notes (caught: %s)',
async (caught) => {
const { supabase } = createSupabaseStub((table) => ({
data:
table === 'catch_records'
? caught
? [
{
id: 'catch',
pokemonId: 1,
userId: 'owner',
pokedexId: 'dex',
personalNotes: 'Saved note'
}
]
: []
: { ...entry, catchInformation: 'Full instructions' },
error: null
}));
const result = await new CombinedDataRepository(supabase, 'owner', 'dex').findEntryDetail(1);
expect(result?.pokedexEntry.catchInformation).toBe('Full instructions');
if (caught) expect(result?.catchRecord?.personalNotes).toBe('Saved note');
else expect(result?.catchRecord).toBeNull();
}
);
it('shares a scoped read between simultaneous rows and count requests', async () => {
const { supabase, queries } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'owner', 'dex');
await Promise.all([
repo.findCombinedData('owner', 1, 30, true, '', 'Scarlet', ['scarlet-paldea']),
repo.countCombinedData(true, '', 'Scarlet', ['scarlet-paldea'])
]);
expect(queryFor(queries, 'game_pokedex_entry_details')).toHaveLength(1);
expect(queryFor(queries, 'pokedex_entries')).toHaveLength(1);
});
it('selects compact columns and does not count the full grid', async () => {
const { supabase, queries } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
await repo.joinGridCatches(await repo.findGridEntries(false, '', []));
expect(queries).toHaveLength(1);
const selection = queries[0].calls.find((call) => call.method === 'select');
expect(selection?.args[0]).not.toContain('*');
expect(selection?.args[0]).not.toContain('notes');
expect(selection?.args[0]).not.toContain('Information');
expect(selection?.args).toHaveLength(1);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterCriticalPageWork, markGridInteractive } from '$lib/utils/criticalPageWork';
describe('optional work scheduling', () => {
let events: EventTarget & Record<string, unknown>;
let idle: (() => void) | undefined;
beforeEach(() => {
vi.useFakeTimers();
events = Object.assign(new EventTarget(), {
setTimeout,
clearTimeout,
requestIdleCallback: vi.fn((callback: () => void) => {
idle = callback;
return 1;
}),
cancelIdleCallback: vi.fn()
});
idle = undefined;
vi.stubGlobal('window', events);
vi.stubGlobal('location', { pathname: '/pokedex/example' });
vi.stubGlobal('document', { querySelector: () => null });
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => setTimeout(callback, 16));
vi.stubGlobal('cancelAnimationFrame', clearTimeout);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('waits for interactive cells, coalesces events, then runs at idle once', () => {
const run = vi.fn();
afterCriticalPageWork(run);
vi.advanceTimersByTime(1000);
expect(run).not.toHaveBeenCalled();
events.dispatchEvent(new Event('livingdex:grid-interactive'));
events.dispatchEvent(new Event('livingdex:grid-interactive'));
vi.advanceTimersByTime(16);
expect(events.requestIdleCallback).toHaveBeenCalledTimes(1);
expect(run).not.toHaveBeenCalled();
idle!();
vi.advanceTimersByTime(5000);
expect(run).toHaveBeenCalledTimes(1);
});
it('falls back after five seconds if no grid becomes interactive', () => {
const run = vi.fn();
afterCriticalPageWork(run);
vi.advanceTimersByTime(4999);
expect(run).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(run).toHaveBeenCalledTimes(1);
});
it('cancels work after navigation or an explicit refresh takes over', () => {
const run = vi.fn();
const cancel = afterCriticalPageWork(run);
events.dispatchEvent(new Event('livingdex:grid-interactive'));
vi.advanceTimersByTime(16);
cancel();
idle!();
vi.advanceTimersByTime(5000);
expect(run).not.toHaveBeenCalled();
});
it('does not announce a grid with no populated cells', () => {
const listener = vi.fn();
events.addEventListener('livingdex:grid-interactive', listener);
markGridInteractive();
expect(listener).not.toHaveBeenCalled();
});
it('schedules other pages without waiting for a grid, even without idle callbacks', () => {
vi.stubGlobal('location', { pathname: '/backup-settings' });
delete events.requestIdleCallback;
const run = vi.fn();
afterCriticalPageWork(run);
vi.advanceTimersByTime(32);
expect(run).toHaveBeenCalledTimes(1);
});
it('marks populated cells once and allows work registered after hydration', () => {
let ready = false;
const grid = {
setAttribute: () => {
ready = true;
}
};
const cell = { closest: () => grid };
vi.stubGlobal('document', {
querySelector: (selector: string) =>
selector === '[data-entry-index]' ? cell : ready ? grid : null
});
const mark = vi.fn();
vi.stubGlobal('performance', { mark });
markGridInteractive();
markGridInteractive();
expect(mark).toHaveBeenCalledTimes(1);
const run = vi.fn();
afterCriticalPageWork(run);
vi.advanceTimersByTime(16);
idle!();
expect(run).toHaveBeenCalledTimes(1);
});
it('does not access the DOM during SSR', () => {
vi.stubGlobal('window', undefined);
expect(() => markGridInteractive()).not.toThrow();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { packGrid, unpackGrid, type PokedexGridRow } from '$lib/models/PokedexGridRow';
describe('grid transport', () => {
it('round-trips missing catches and every flag combination', () => {
const rows: PokedexGridRow[] = Array.from({ length: 17 }, (_, flags) => ({
pokedexEntry: {
_id: String(flags),
pokedexNumber: 25,
pokemon: 'Pikachu',
form: 'Female',
spriteKey: '25',
canGigantamax: true
},
catchRecord:
flags === 16
? null
: {
_id: `catch-${flags}`,
caught: !!(flags & 1),
haveToEvolve: !!(flags & 2),
inHome: !!(flags & 4),
hasGigantamaxed: !!(flags & 8)
}
}));
expect(unpackGrid(packGrid(rows))).toEqual(rows);
});
});