Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/web/src/lib/reference.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
44 changes: 38 additions & 6 deletions apps/web/src/lib/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReferenceCategory> = 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;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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' };
Expand Down
Loading