Defend against stale skill-tree JSON cache

A friend on Chrome hit "C.subtrees is not iterable" — Chrome was serving
the previous skill-tree JSON shape ({nodes, edges} at top level) out of
the disk cache while running the new bundle that expects {subtrees}.
Hard reload fixed his session but other users could hit the same.

Three layers of defense:
1. backend/data.controller.ts: Cache-Control switches from
   'public, max-age=3600' to 'no-cache, must-revalidate' so the browser
   always revalidates JSON files against the server.
2. frontend/data.ts: every data fetch gets ?v=2 + cache:'no-cache' to
   bust any existing browser/CDN entry.
3. frontend/data.ts loadSkillTree(): if the response still has the legacy
   shape (no subtrees, but nodes array), wrap it in a single anonymous
   subtree so the UI doesn't crash on iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-05-23 08:33:30 -04:00
parent 9f53a58701
commit 25083d5572
2 changed files with 28 additions and 3 deletions

View file

@ -35,7 +35,10 @@ const ALLOWED = new Set<string>([
export class DataController { export class DataController {
@Get(':file') @Get(':file')
@Header('Content-Type', 'application/json; charset=utf-8') @Header('Content-Type', 'application/json; charset=utf-8')
@Header('Cache-Control', 'public, max-age=3600') // These files change with the data extractor and schema. A 3600s cache
// bit us once — a browser held an older shape after a schema migration
// and crashed the UI. Force revalidation per request.
@Header('Cache-Control', 'no-cache, must-revalidate')
one(@Param('file') file: string, @Res() res: Response) { one(@Param('file') file: string, @Res() res: Response) {
if (!ALLOWED.has(file)) { if (!ALLOWED.has(file)) {
throw new BadRequestException('unknown data file'); throw new BadRequestException('unknown data file');

View file

@ -10,7 +10,11 @@ import type {
const API = '/api/data'; const API = '/api/data';
async function getJSON<T>(file: string): Promise<T> { async function getJSON<T>(file: string): Promise<T> {
const res = await fetch(`${API}/${file}`); // cache-bust against stale browser caches that hold an older schema for
// these files. The query string is ignored server-side but defeats any
// long-lived Cache-Control we (or a CDN) may have previously sent.
const url = `${API}/${file}?v=2`;
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) throw new Error(`fetch ${file}: ${res.status}`); if (!res.ok) throw new Error(`fetch ${file}: ${res.status}`);
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
@ -49,5 +53,23 @@ export async function loadFaction(house: House): Promise<FactionTable> {
} }
export async function loadSkillTree(id: ClassId): Promise<SkillTree> { export async function loadSkillTree(id: ClassId): Promise<SkillTree> {
return getJSON<SkillTree>(`skills-${id}.json`); const raw = await getJSON<any>(`skills-${id}.json`);
// Backwards-compat: an older snapshot of this file had {nodes, edges} at
// the top level instead of {subtrees}. Treat it as a single anonymous
// subtree so the UI doesn't blow up on iteration.
if (raw && !raw.subtrees && Array.isArray(raw.nodes)) {
return {
id: raw.id,
name: raw.name,
subtrees: [
{
name: '',
cols: Math.max(1, ...raw.nodes.map((n: any) => n.col || 1)),
nodes: raw.nodes,
edges: raw.edges || [],
},
],
} as SkillTree;
}
return raw as SkillTree;
} }