diff --git a/apps/web/src/lib/reference.test.ts b/apps/web/src/lib/reference.test.ts new file mode 100644 index 0000000..3714073 --- /dev/null +++ b/apps/web/src/lib/reference.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { kbCacheOpts, LARGE_CATEGORIES } from './reference'; +import type { ReferenceCategory } from './reference-types'; + +// Snapshot + restore the env knob so a test that flips it doesn't +// poison the others. vitest doesn't isolate `process.env` per-test. +let savedDisable: string | undefined; +beforeEach(() => { + savedDisable = process.env.STARSTATS_DISABLE_FETCH_CACHE; + delete process.env.STARSTATS_DISABLE_FETCH_CACHE; +}); +afterEach(() => { + if (savedDisable === undefined) { + delete process.env.STARSTATS_DISABLE_FETCH_CACHE; + } else { + process.env.STARSTATS_DISABLE_FETCH_CACHE = savedDisable; + } +}); + +describe('LARGE_CATEGORIES', () => { + it('marks item and vehicle as large (>2 MB)', () => { + expect(LARGE_CATEGORIES.has('item')).toBe(true); + expect(LARGE_CATEGORIES.has('vehicle')).toBe(true); + }); + + it('leaves weapon and location cacheable (<2 MB)', () => { + expect(LARGE_CATEGORIES.has('weapon')).toBe(false); + expect(LARGE_CATEGORIES.has('location')).toBe(false); + }); +}); + +describe('kbCacheOpts', () => { + it('returns a 1h revalidate by default (no category arg)', () => { + expect(kbCacheOpts()).toEqual({ next: { revalidate: 3600 } }); + }); + + it('caches small categories with the 1h revalidate', () => { + const small: ReferenceCategory[] = ['weapon', 'location']; + for (const cat of small) { + expect(kbCacheOpts(cat)).toEqual({ next: { revalidate: 3600 } }); + } + }); + + it('bypasses the data cache for large categories', () => { + const large: ReferenceCategory[] = ['item', 'vehicle']; + for (const cat of large) { + expect(kbCacheOpts(cat)).toEqual({ cache: 'no-store' }); + } + }); + + it('honours STARSTATS_DISABLE_FETCH_CACHE for every category', () => { + process.env.STARSTATS_DISABLE_FETCH_CACHE = '1'; + const every: ReferenceCategory[] = ['vehicle', 'weapon', 'item', 'location']; + for (const cat of every) { + expect(kbCacheOpts(cat)).toEqual({ cache: 'no-store' }); + } + // And the no-category variant. + expect(kbCacheOpts()).toEqual({ cache: 'no-store' }); + }); +}); diff --git a/apps/web/src/lib/reference.ts b/apps/web/src/lib/reference.ts index c6e0c86..bf64cca 100644 --- a/apps/web/src/lib/reference.ts +++ b/apps/web/src/lib/reference.ts @@ -56,22 +56,54 @@ import { // `server-only`. export * from './reference-types'; +/** + * Categories whose full listing exceeds Next 15's hardcoded 2 MB + * per-entry data-cache limit. Trying to cache them logs a recurring + * "Failed to set Next.js data cache for … items over 2MB can not + * be cached (N bytes)" + * warning on every fetch with no actual cache benefit. Opt out with + * `no-store` so the warning stops and we don't pay the futile + * serialization cost on every request. React's request-level fetch + * dedup still applies — same-URL fetches within a single server + * render share an upstream call regardless of cache mode. + * + * Sizes as of 2026-05-31: + * - item: ~3.4 MB + * - vehicle: ~4 MB (per memory: wiki-reference-page-sizes) + * `weapon` + `location` stay <1 MB and remain cacheable. + * + * If another category grows past 2 MB, add it here; the + * symptom is the cache-warning log line above. + */ +/** @internal exported for testing */ +export const LARGE_CATEGORIES: ReadonlySet = new Set([ + 'item', + 'vehicle', +]); + /** * Per-fetch cache directive for reference endpoints. * * Production: 1h revalidate is fine — wiki sync is daily, an hour - * stale is invisible to users. + * stale is invisible to users. Large categories (see + * `LARGE_CATEGORIES`) bypass the data cache; everything else gets + * the 1h revalidate. * * Playwright e2e: cache leaks between scenarios because Next holds * `revalidate` responses across `page.goto()` calls within a single * dev-server lifetime. Setting `STARSTATS_DISABLE_FETCH_CACHE=1` - * (the Playwright webServer env) flips to `no-store` so each - * scenario's `setScenario` is honored by the next render. + * (the Playwright webServer env) flips to `no-store` for every + * category so each scenario's `setScenario` is honored by the next + * render. */ -function kbCacheOpts(): RequestInit { +/** @internal exported for testing */ +export function kbCacheOpts(category?: ReferenceCategory): RequestInit { if (process.env.STARSTATS_DISABLE_FETCH_CACHE === '1') { return { cache: 'no-store' }; } + if (category && LARGE_CATEGORIES.has(category)) { + return { cache: 'no-store' }; + } return { next: { revalidate: 3600 } } as RequestInit; } @@ -102,7 +134,7 @@ export async function getCategoryBundle( try { const resp = await fetch(`${apiBase()}/v1/reference/${category}`, { method: 'GET', - ...kbCacheOpts(), + ...kbCacheOpts(category), }); if (!resp.ok) { // Log non-2xx; reference data is cosmetic-but-not-invisible, @@ -171,7 +203,7 @@ export async function getEntityDetail( `${apiBase()}/v1/reference/${category}/slug/${encodeURIComponent(slug)}`, { method: 'GET', - ...kbCacheOpts(), + ...kbCacheOpts(category), }, ); if (resp.status === 404) return { kind: 'not_found' };