Files
LivingDexTracker/scripts/performance/verify.mjs
T
Josh Creek c917bba54a fix(pokedex): open detail cards without a per-card round trip
Since the grid transport change, opening a Pokémon fetched
/api/pokedexes/[id]/entries/[entryId]. That request re-scanned the whole
dex to authorise one entry, on top of an auth call and an ownership
query, and production runs its functions in a different region from the
database — so each card took seconds to fill in. The page's 60-second
reconcile also wiped the detail cache, making cards refetch about once a
minute.

Details are catalog text plus personal notes, so the dex is now read once
per account/dex in the background at the interactive/idle boundary and
fills that cache; a card opens straight from memory. That read reuses the
existing combined-data endpoint, which gains an opt-out for its count
query since a caller taking every row already knows the total.

The per-entry endpoint stays as the fallback for a card opened before the
background read lands, and now checks membership for the single entry
instead of materialising the dex. Catch status still comes from the live
grid row and any queued write, so a cached detail cannot show stale
progress.

The packed grid transport and virtualised rendering are unchanged.
2026-09-18 15:28:55 +01:00

145 lines
6.6 KiB
JavaScript

import { chromium } from '@playwright/test';
import { readFile, writeFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
const directory = process.env.PERF_FIXTURE_DIR ?? '/tmp/livingdex-grid-performance';
const fixture = JSON.parse(await readFile(`${directory}/fixture.json`, 'utf8'));
const baseURL = process.env.PERF_BASE_URL ?? 'http://127.0.0.1:4173';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
baseURL,
storageState: `${directory}/storage-state.json`,
viewport: { width: 1350, height: 940 }
});
await context.addInitScript(() => {
window.__layoutShifts = [];
window.__firstVisible = null;
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
if (!entry.hadRecentInput) window.__layoutShifts.push(entry.value);
}).observe({ type: 'layout-shift', buffered: true });
function visible() {
const cell = document.querySelector('[data-entry-index]');
if (
cell &&
cell.getBoundingClientRect().height > 0 &&
cell.getBoundingClientRect().top < innerHeight
)
window.__firstVisible ??= performance.now();
if (window.__firstVisible === null) requestAnimationFrame(visible);
}
requestAnimationFrame(visible);
});
const page = await context.newPage();
const readJson = (path) =>
page.evaluate(async (path) => {
const response = await fetch(path);
if (!response.ok) throw new Error(`Fixture API failed: ${response.status}`);
return response.json();
}, path);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const requests = [];
page.on('request', (request) => requests.push(new URL(request.url()).pathname));
const primedDetails = () =>
requests.filter((path) => /\/api\/pokedexes\/[^/]+\/combined-data$/.test(path)).length;
// Details are read once per dex at the idle boundary, so allow for a slow idle callback.
async function waitForDetailPrime() {
const deadline = Date.now() + 15_000;
while (primedDetails() === 0 && Date.now() < deadline) await page.waitForTimeout(100);
assert.equal(primedDetails(), 1, 'Details must be read exactly once for the dex');
}
const results = [];
try {
for (const dex of fixture.dexes) {
requests.length = 0;
const response = await page.goto(`/pokedex/${dex.id}`);
assert.equal(response.status(), 200);
const html = await response.text();
assert.ok(/data-entry-index=["']?0["'\s>]/.test(html), 'Initial cells must be server rendered');
await page.waitForSelector('[data-grid-interactive]');
await page.waitForTimeout(1000);
const stats = await page.evaluate(() => ({
cells: document.querySelectorAll('[data-entry-index]').length,
dom: document.querySelectorAll('*').length,
cls: window.__layoutShifts.reduce((sum, value) => sum + value, 0),
firstVisible: window.__firstVisible,
interactive: performance.getEntriesByName('pokedex:first-interactive').at(-1)?.startTime
}));
assert.ok(stats.cells <= 180, `Mounted cells: ${stats.cells}`);
assert.ok(stats.dom < 2500, `DOM elements: ${stats.dom}`);
assert.ok(stats.cls <= 0.1, `CLS: ${stats.cls}`);
// The grid ships with the page, so re-fetching it would be redundant. The details read is
// deliberate: it is what lets a card open without a round trip of its own.
assert.equal(requests.filter((path) => /\/api\/pokedexes\/[^/]+\/grid$/.test(path)).length, 0);
await waitForDetailPrime();
assert.equal(await page.locator('button button').count(), 0);
await page.screenshot({ path: `${directory}/${dex.gameScope ? 'scoped' : 'national'}.png` });
const first = page.locator('[data-entry-index="0"]');
const entryId = await first.getAttribute('data-entry-id');
const notes = await readJson(`/api/pokedexes/${dex.id}/entries/${entryId}`);
const openedWith = requests.filter((path) => path.endsWith(`/entries/${entryId}`)).length;
await first.click();
const modal = page.getByRole('dialog', { name: 'Pokémon details' });
await modal.getByLabel('Notes:', { exact: true }).waitFor();
// Primed details mean opening a card costs no request of its own.
assert.equal(
requests.filter((path) => path.endsWith(`/entries/${entryId}`)).length,
openedWith,
'Opening a card must not fetch its details separately'
);
assert.equal(
await modal.getByLabel('Notes:', { exact: true }).inputValue(),
notes.catchRecord.personalNotes
);
const image = modal.locator('img').first();
await image.waitFor();
await page.waitForFunction(
() => document.querySelector('[role="dialog"] img')?.naturalWidth > 0
);
assert.equal(await image.evaluate((node) => node.naturalWidth), 512);
assert.ok(!(await image.getAttribute('src')).includes('sprites-grid'));
await modal.getByRole('button', { name: 'Close', exact: true }).click();
assert.equal(await first.evaluate((node) => document.activeElement === node), true);
// Reopening uses cached details; bulk edits must retain the unloaded personal notes.
const detailCalls = () =>
requests.filter((path) => path.endsWith(`/entries/${entryId}`)).length;
const before = detailCalls();
await first.click();
await modal.getByLabel('Notes:', { exact: true }).waitFor();
assert.equal(detailCalls(), before);
await modal.getByRole('button', { name: 'Close', exact: true }).click();
await page.getByRole('button', { name: 'Open bulk actions menu' }).first().click();
await page.getByRole('button', { name: 'Mark box as In HOME', exact: true }).click();
await page.waitForResponse(
(r) =>
r.url().endsWith(`/pokedexes/${dex.id}/catch-records`) && r.request().method() === 'POST'
);
const after = await readJson(`/api/pokedexes/${dex.id}/entries/${entryId}`);
assert.equal(after.catchRecord.personalNotes, notes.catchRecord.personalNotes);
assert.equal(after.catchRecord.inHome, true);
// Focus navigation must reach entries that were not initially mounted.
await page.locator('[data-entry-index="29"]').focus();
for (let index = 0; index < 22; index++) await page.keyboard.press('ArrowDown');
assert.equal(
await page.evaluate(() => document.activeElement?.getAttribute('data-entry-index')),
'161'
);
await page.getByLabel('Render all boxes').check();
const grid = await readJson(`/api/pokedexes/${dex.id}/grid`);
assert.equal(await page.locator('[data-entry-index]').count(), grid.grid.length);
await page.getByLabel('Render all boxes').uncheck();
results.push({
fixture: dex.gameScope ? 'scoped-forms' : 'national',
...stats,
documentBytes: Buffer.byteLength(html),
gridBytes: Buffer.byteLength(JSON.stringify(grid))
});
}
assert.deepEqual(errors, [], 'Browser errors');
await writeFile(`${directory}/verification.json`, JSON.stringify(results, null, 2));
console.log(JSON.stringify(results, null, 2));
} finally {
await browser.close();
}