mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-18 19:42:04 +00:00
c917bba54a
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.
117 lines
12 KiB
Markdown
117 lines
12 KiB
Markdown
# Pokédex performance implementation
|
||
|
||
Status: application changes implemented; hosting comparison remains gated. Measurements below are local production-build evidence, not deployed latency improvements. See [hosting evaluation](pokedex-hosting-evaluation.md) for the migration decision and remaining work.
|
||
|
||
## Application changes
|
||
|
||
- Ownership and saved scope links are read together. Scoped entry retrieval is shared by rows/count consumers; the full grid performs no count query. Catch joins use ID maps. Scope deduplication, named default forms, supplements and ordering remain covered by repository tests.
|
||
- The page awaits the entire compact grid and renders initial boxes on the server. Authentication state reaches SSR through the validated layout user. Successful navigation needs no grid API request; failed grid loads expose retry.
|
||
- `PokedexGridRow` contains identity, sprite resolution fields and catch flags. The page and authenticated `/api/pokedexes/[id]/grid` endpoint transport named tuples defined in `PokedexGridRow.ts`; `packGrid`/`unpackGrid` keep that wire format out of components. Instructions, notes, origin games and repeated owner/dex IDs are absent. Existing full combined-data consumers retain their contracts.
|
||
- Details for the whole dex are read once per account/dex in the background, at the same interactive/idle boundary as the other deferred startup work, and fill the modal's cache. Opening a card therefore renders from memory with no request of its own: a per-card round trip cost seconds on the deployed cross-region setup, which local timings did not show. That read reuses `/api/pokedexes/[id]/combined-data`, which now skips its count query when a caller asks for every row at once.
|
||
- The per-entry detail endpoint remains as the fallback for a card opened before the background read lands, and for a failed read. It verifies ownership and membership before returning one full `CombinedData` row, checking membership for the single entry rather than materialising the dex. The modal opens immediately with identity and full artwork, then fills in editable details. Its account/dex/entry cache, abort/sequence checks and pending-patch merge protect rapid selection changes and optimistic edits; catch status always comes from the live grid row, so a cached detail can never show stale progress.
|
||
- Status writes send changed fields. Bulk writes group records by supplied columns, preserving omitted notes and flags; explicit empty notes clear them. New records use database defaults. Full-record callers and exports remain supported. Bulk box actions still target all original 30 slots, including dimmed entries.
|
||
- Grid placeholders preserve geometry; visible boxes and one row of overscan mount populated cells. Focused boxes remain mounted, keyboard navigation crosses boundaries, modal close restores focus, and an accessible render-all option exposes the complete document. Each Pokémon uses one button with identity/status and a noninteractive tooltip.
|
||
- Density is persisted in a cookie for stable SSR geometry and in local storage. Resize/density changes preserve the current box anchor. Existing local-storage-only preferences are replaced by the cookie after choosing a density.
|
||
- Automatic snapshot/backup work starts at the interactive/idle boundary with a five-second fallback. Concurrent backup refreshes coalesce. Explicit sync and edit/account/reconnect invalidations retain freshness behavior. Backup-status GET no longer revalidates via `setSession`.
|
||
- Complete offline snapshot format 2 is retained. Offline modal details come only from the matching account snapshot; missing copies report unavailable details. Offline details are read-only, as before for offline mutations.
|
||
|
||
## Artwork and build behavior
|
||
|
||
`npm run sprites:grid` generates 3,170 first-party WebP thumbnails, at most 128 pixels per side, quality 80. Builds run this automatically. Generated assets live in ignored `static/sprites-grid/v1/`; the generator emits a manifest. Originals remain 512 pixels for details and explicit full-artwork downloads.
|
||
|
||
The generated catalog is 10,007,504 bytes versus 53,659,230 bytes for the source WebP catalog (81.4% smaller). Shiny, female and named-form resolution uses the existing key/fallback chain. The service worker recognizes versioned grid sprites in the existing artwork cache; neither thumbnail nor original catalogs are blanket precached. Root `_headers` supplies immutable caching on Netlify/Cloudflare. Bump the URL version whenever generation settings or source images change; generation skips existing files within a version.
|
||
|
||
## Local acceptance evidence
|
||
|
||
Fixtures: national (1,025 entries), Scarlet/Paldea forms (439 entries), mixed catch flags and nonempty notes. Integration tests additionally cover missing catches and overlapping scopes through repository tests. Browser viewport: 1,350 × 940, comfortable density, Chromium, local Node production build and local seeded Supabase.
|
||
|
||
The packed grid is approximately 81 KB national and 36 KB scoped, versus the investigation's approximately 593 KB and 255 KB full-data JSON. The same-fixture integration assertion separately verifies at least 60% reduction against full combined rows. These are serialized row sizes, not compressed HTML document sizes.
|
||
|
||
The local browser checks verify at most 180 populated mounted cells, fewer than 2,500 DOM elements and CLS at most 0.1; observed initial population is 120 cells. Density/mobile checks include all three densities at 1,350 and 390 pixels. Detail artwork is asserted to load at 512 pixels. No redundant grid request is allowed; the dex is read for details exactly once, and opening a card afterwards must make no request at all. The offline-isolation checks block that background read so the snapshot path, not the primed cache, answers those clicks.
|
||
|
||
Warm results from the corrected harness (30 samples per row; [sanitized summary](pokedex-local-results.json)):
|
||
|
||
| Fixture | Navigation | First visible p75 | First interactive p75 | Decoded response bytes p75 | Maximum DOM |
|
||
| ------------ | ---------- | ----------------: | --------------------: | -------------------------: | ----------: |
|
||
| National | Direct | 123.5 ms | 145.4 ms | 248,668 | 2,088 |
|
||
| National | Client | 495.6 ms | 494.1 ms | 102,687 | 2,090 |
|
||
| Scoped forms | Direct | 114.5 ms | 133.1 ms | 203,601 | 2,073 |
|
||
| Scoped forms | Client | 491.7 ms | 490.6 ms | 45,645 | 2,075 |
|
||
|
||
Every warm sample had 120 populated cells, zero observed CLS, zero redundant grid requests and a measured response size. Direct response bytes include SSR markup and SvelteKit data; client bytes are SvelteKit data responses, not just the packed rows. Interactive marks can precede the next animation-frame visibility observation by a few milliseconds. Client timings include Playwright click overhead, as explained below. This benchmark preceded the final account-switch backup invalidation guard; the final production smoke suite also passed after that guard.
|
||
|
||
### Validation completed
|
||
|
||
| Check | Result |
|
||
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||
| Svelte/TypeScript | 0 errors; one existing `Tooltip.svelte` CSS `@apply` warning |
|
||
| Unit tests | 193 passed |
|
||
| Data tests | 18 passed |
|
||
| Coverage | Existing thresholds passed: 66.81% lines/statements, 88.79% branches, 90.99% functions |
|
||
| Local Supabase integration | 16 passed, including same-fixture payload reduction and note preservation |
|
||
| Authenticated browser regressions | 53 passed, including mock Google/Dropbox OAuth, exports and service-worker/offline flows |
|
||
| Build matrix | Four checks in each of Netlify/Node × generateSW/injectManifest passed |
|
||
| Production performance/behavior | Both fixtures, all densities/mobile, resize anchor, focus/keyboard, modal race, full artwork and offline snapshot isolation passed |
|
||
| Cloudflare | Final build and Wrangler dry-run passed; local workerd share-image generation failed as documented in the hosting report |
|
||
| Formatting/lint | All changed/new implementation files pass Prettier; ESLint and `git diff --check` pass |
|
||
|
||
Repository-wide `npm run lint` also scans the pre-existing untracked `dex.har` and `POKEDEX_PERFORMANCE_PROMPT.md`; those two files have formatting warnings and were intentionally left untouched. No coverage threshold was lowered. Disposable performance accounts and local test servers were cleaned up.
|
||
|
||
### Reproduce
|
||
|
||
```sh
|
||
npm ci
|
||
npx supabase start
|
||
npx playwright install chromium
|
||
npm run check
|
||
npm run test:unit
|
||
npm run test:integration
|
||
npm run test:build
|
||
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER=true npm run test:bdd
|
||
npm run test:performance
|
||
PERF_BENCHMARK=true npm run test:performance
|
||
```
|
||
|
||
Run builds sequentially: they share `.svelte-kit` and `build`. Performance tests create a disposable local-only account, use private temporary session files, clean up afterward, and copy sanitized reports/screenshots to ignored `test-results/performance/`. Cleanup failures retain recovery files. Never commit session/configuration files.
|
||
|
||
## Instrumentation and comparison harness
|
||
|
||
Set `POKEDEX_PERFORMANCE=true` on a preview to emit fixed-label `Server-Timing` stages: auth, ownership, scopes, entries, catches, preparation and total. Timings do not include IDs, cookies, notes or query text. Authentication includes the request hook's validation. Total measures the page/service span, not total platform request lifetime; stages may therefore not sum to total.
|
||
|
||
The browser harness measures first visible cells via animation frames and first interactive cells via `pokedex:first-interactive`, independently of response completion. It collects DOM/cells, layout shifts, intentional/redundant API counts and response sizes. For Chromium service-worker responses where Playwright cannot read the body, decoded Resource Timing bytes are used; absent measurements stay null. Decoded, encoded and transferred bytes are distinguished.
|
||
|
||
`benchmark.mjs` requires at least 30 warm samples per fixture/navigation combination. It records the first observed run separately; that is **not** evidence of a cold start or first-after-idle run. For real-host work, collect deliberately idled runs separately and record the idle interval. Client timing starts before Playwright's click, so includes its actionability overhead; preserve identical tooling/settings between hosts and report this limitation.
|
||
|
||
Private configuration example (replace all placeholders):
|
||
|
||
```json
|
||
{
|
||
"samples": 30,
|
||
"revision": "exact-deployed-commit",
|
||
"databaseLabel": "same-staging-project",
|
||
"clientLocation": "London-fixed-runner",
|
||
"environment": "deployed",
|
||
"compatibilityPassed": false,
|
||
"hosts": [
|
||
{
|
||
"label": "netlify",
|
||
"url": "https://NETLIFY-PREVIEW",
|
||
"storageState": "/PRIVATE/netlify-session.json",
|
||
"fixtures": [
|
||
{ "id": "NATIONAL-ID", "name": "Performance National", "label": "national" },
|
||
{ "id": "SCOPED-ID", "name": "Performance Scarlet Forms", "label": "scoped-forms" }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Add an equivalent `cloudflare` host using the same database and fixtures. Run:
|
||
|
||
```sh
|
||
node scripts/performance/benchmark.mjs /PRIVATE/config.json /PRIVATE/results.json
|
||
node scripts/performance/compare.mjs /PRIVATE/results.json
|
||
```
|
||
|
||
The comparison refuses insufficient samples and retains Netlify unless deployed evidence, compatibility and the agreed improvement thresholds all pass. Human review must still establish costs, environment equivalence and deployment prerequisites.
|