Skip to content

Commit f6585fe

Browse files
committed
stop precaching every page in offline downloads, just data+images
The "Download for offline" flow was fetching ~3800 individual page documents on top of the compact JSON bundles those pages already render from client-side, duplicating the same data and inflating the job from ~430 files to 4300+. That volume was also almost certainly why progress could get stuck partway with no way to clear the cache: a single hung fetch with no timeout could freeze the whole batch loop indefinitely. Drop page-manifest generation and page caching entirely — pages you actually visit are still cached automatically by next-pwa's normal runtime caching — and add a 15s per-file timeout so a stalled request can no longer wedge the download.
1 parent 5784101 commit f6585fe

5 files changed

Lines changed: 48 additions & 84 deletions

File tree

‎scripts/generate-data.ts‎

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -272,21 +272,6 @@ function push(name: string, obj: unknown) {
272272
files.push({ name, json: JSON.stringify(obj) });
273273
}
274274

275-
// Every real page route in the site (list + detail pages), used by the
276-
// "Download for offline" control to precache page documents alongside data,
277-
// so navigating between pages works fully offline right after downloading
278-
// instead of only for pages the visitor happens to have already opened.
279-
const pageManifest: string[] = [
280-
"/",
281-
"/blacksmith/",
282-
"/armorsmith/",
283-
"/decorations/",
284-
"/monsters/",
285-
"/calculator/",
286-
"/hunter/",
287-
"/options/",
288-
];
289-
290275
// Weapons
291276
const melodies = readJson<Record<string, Melody[]>>(
292277
path.join(contentDir, "blacksmith", "hunting-horn", "hunting-horn-melodies.json")
@@ -311,8 +296,6 @@ for (const type of WEAPON_TYPES) {
311296
tree: compactTree(tree.map, type),
312297
items,
313298
});
314-
pageManifest.push(`/blacksmith/${type}/`);
315-
for (const w of items) pageManifest.push(`/blacksmith/${type}/${w.s}/`);
316299
}
317300

318301
// Armor — data/armor/<slot>.json holds every rank in one file (each piece
@@ -340,8 +323,6 @@ for (const slot of ARMOR_SLOTS) {
340323
tree: compactTree(tree.map, slot),
341324
items,
342325
});
343-
pageManifest.push(`/armorsmith/${slot}/${rank}/`);
344-
for (const a of items) pageManifest.push(`/armorsmith/${slot}/${rank}/${a.s}/`);
345326
}
346327
}
347328

@@ -353,8 +334,6 @@ for (const category of MONSTER_CATEGORIES) {
353334
const items = data.monsters.map((m) => encodeMonster({ ...m, slug: slugify(m.name) }));
354335
totalItems += items.length;
355336
push(`monster-${category}.json`, { kind: "monster", category, items });
356-
pageManifest.push(`/monsters/${category}/`);
357-
for (const m of items) pageManifest.push(`/monsters/${category}/${m.s}/`);
358337
}
359338

360339
// Decorations
@@ -374,7 +353,6 @@ for (const category of MONSTER_CATEGORIES) {
374353
tree: compactTree(tree.map, "decoration"),
375354
items,
376355
});
377-
for (const d of items) pageManifest.push(`/decorations/${d.s}/`);
378356
}
379357

380358
// ---------------------------------------------------------------------------
@@ -448,10 +426,6 @@ walkImages(imagesDir);
448426
imageUrls.sort();
449427
fs.writeFileSync(path.join(outDir, "image-manifest.json"), JSON.stringify(imageUrls));
450428

451-
// Page manifest for the offline-download UI: every real route in the site,
452-
// so "Download for offline" can precache page documents, not just data.
453-
fs.writeFileSync(path.join(outDir, "page-manifest.json"), JSON.stringify(pageManifest));
454-
455429
const fmt = (n: number) =>
456430
n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(2)} MB` : `${(n / 1024).toFixed(1)} KB`;
457431

‎src/components/OfflineDownload.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ function remembered(): string | null {
2424
}
2525

2626
/**
27-
* Prompts (once) to download data + pages for offline use, via a toast rather
28-
* than an always-on homepage control. Sticky until the user downloads or
29-
* dismisses; not shown again after either. A persistent, non-dismissable
27+
* Prompts (once) to download the site's data for offline use, via a toast
28+
* rather than an always-on homepage control. Sticky until the user downloads
29+
* or dismisses; not shown again after either. A persistent, non-dismissable
3030
* equivalent lives on the options page (OfflineSettings) for anyone who
3131
* dismissed this or wants to re-download later — both share the caching
32-
* logic via useOfflineDownload so they never disagree about what's cached.
32+
* logic via OfflineContext so they never disagree about what's cached.
3333
*/
3434
export default function OfflineDownload() {
3535
const { toast, update, dismiss } = useToast();
@@ -55,7 +55,7 @@ export default function OfflineDownload() {
5555
message:
5656
offline.cached > 0
5757
? `Partially cached (${offline.cached}/${offline.total}). Finish downloading so the smithy works without a connection.`
58-
: "Save weapons, armor, monsters, and pages so the smithy works without a connection.",
58+
: "Save weapon, armor, and monster data so search, the calculator, and browsing work without a connection.",
5959
duration: null,
6060
actions: [
6161
{ label: "Download", onClick: () => offline.download() },

‎src/components/OfflineSettings.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@ export default function OfflineSettings() {
4949
<Card className={styles.section}>
5050
<h2 className={styles.heading}>Offline Data</h2>
5151
<p className={styles.note}>
52-
Save weapons, armor, monsters, and pages to this device so the smithy
53-
works without a connection. This stays here whenever you want to
54-
(re)download or clear it — unlike the one-time popup.
52+
Save weapon, armor, monster, and decoration data to this device so
53+
search, the calculator, and browsing work without a connection. This
54+
stays here whenever you want to (re)download or clear it — unlike the
55+
one-time popup.
5556
</p>
5657

5758
<div className={styles.status}>

‎src/context/OfflineContext.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
collectOfflineUrls,
1515
countCached,
1616
downloadOffline,
17-
totalUrls,
1817
} from "@/lib/offline-cache";
1918

2019
export type OfflineDownloadStatus =
@@ -75,7 +74,7 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
7574
setState((s) => ({ ...s, status: "checking" }));
7675
try {
7776
const urls = await collectOfflineUrls();
78-
const total = totalUrls(urls);
77+
const total = urls.length;
7978
const cached = await countCached(urls);
8079
if (!mounted.current) return;
8180
setState({ status: cached >= total ? "complete" : "idle", cached, total, progress: 0 });
@@ -89,7 +88,7 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
8988
setState((s) => ({ ...s, status: "downloading", progress: 0 }));
9089
try {
9190
const urls = await collectOfflineUrls();
92-
const total = totalUrls(urls);
91+
const total = urls.length;
9392
const cached = await downloadOffline(urls, (done, jobTotal) => {
9493
if (!mounted.current) return;
9594
setState((s) => ({

‎src/lib/offline-cache.ts‎

Lines changed: 37 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,83 @@
11
const RUNTIME_CACHE = "mhfu-offline-v1";
2-
// Matches the cacheName next-pwa's default "pages" runtime-caching rule uses
3-
// for plain document navigations, so pages we precache here are found by it.
4-
const PAGES_CACHE = "pages";
52

63
interface Manifest {
74
version?: string;
85
files?: string[];
96
}
107

11-
export interface OfflineUrlSet {
12-
data: string[];
13-
pages: string[];
14-
}
15-
16-
export async function collectOfflineUrls(): Promise<OfflineUrlSet> {
8+
/**
9+
* Offline downloads only cover the compact data bundles + images — the raw
10+
* material every page renders from client-side (see src/lib/client-data.ts).
11+
* We deliberately don't precache individual page documents here: with ~3800
12+
* weapon/armor/monster/decoration routes, that meant thousands of near-
13+
* identical HTML/RSC fetches for pages whose actual content is just this
14+
* same data rendered client-side, which was slow and mostly redundant bytes.
15+
* Pages you actually visit still get cached for offline reuse automatically
16+
* by next-pwa's normal runtime caching (the "pages"/"pages-rsc" rules in
17+
* next.config.ts) — this button just guarantees the underlying data is there
18+
* up front so search, the calculator, and any page you do open work offline.
19+
*/
20+
export async function collectOfflineUrls(): Promise<string[]> {
1721
const manifest: Manifest = await fetch("/data/data-manifest.json").then((r) =>
1822
r.json()
1923
);
20-
const data = (manifest.files ?? []).map((f) => `/data/${f}`);
21-
// images manifest — generated alongside the data manifest
24+
const urls = (manifest.files ?? []).map((f) => `/data/${f}`);
2225
try {
2326
const images: string[] = await fetch("/data/image-manifest.json").then((r) =>
2427
r.json()
2528
);
26-
data.push(...images);
29+
urls.push(...images);
2730
} catch {
2831
// image manifest missing; data-only offline is still useful
2932
}
30-
data.push("/search-data.json", "/weapon-data.json");
31-
32-
let pages: string[] = [];
33-
try {
34-
pages = await fetch("/data/page-manifest.json").then((r) => r.json());
35-
} catch {
36-
// page manifest missing (e.g. older build); data-only offline still works
37-
}
38-
39-
return { data, pages };
40-
}
41-
42-
export function totalUrls({ data, pages }: OfflineUrlSet): number {
43-
return data.length + pages.length;
33+
urls.push("/search-data.json", "/weapon-data.json");
34+
return urls;
4435
}
4536

46-
export async function countCached({ data, pages }: OfflineUrlSet): Promise<number> {
37+
export async function countCached(urls: string[]): Promise<number> {
4738
try {
48-
const dataCache = await caches.open(RUNTIME_CACHE);
49-
const pagesCache = await caches.open(PAGES_CACHE);
39+
const cache = await caches.open(RUNTIME_CACHE);
5040
let n = 0;
51-
for (const u of data) if (await dataCache.match(u)) n++;
52-
for (const u of pages) if (await pagesCache.match(u)) n++;
41+
for (const u of urls) if (await cache.match(u)) n++;
5342
return n;
5443
} catch {
5544
return 0;
5645
}
5746
}
5847

48+
const FETCH_TIMEOUT_MS = 15000;
49+
5950
export async function downloadOffline(
60-
urls: OfflineUrlSet,
51+
urls: string[],
6152
onProgress: (done: number, total: number) => void
6253
): Promise<number> {
63-
const dataCache = await caches.open(RUNTIME_CACHE);
64-
const pagesCache = await caches.open(PAGES_CACHE);
65-
const jobs = [
66-
...urls.data.map((u) => ({ u, cache: dataCache })),
67-
...urls.pages.map((u) => ({ u, cache: pagesCache })),
68-
];
54+
const cache = await caches.open(RUNTIME_CACHE);
6955

7056
// Download in small batches for progress without hammering the server.
57+
// Each fetch is time-boxed so one stalled request can't freeze the whole
58+
// download — it just counts as failed and the batch moves on.
7159
const BATCH = 20;
72-
const fetchInto = async (u: string, cache: Cache) => {
60+
const fetchInto = async (u: string) => {
61+
const controller = new AbortController();
62+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
7363
try {
74-
const res = await fetch(u, { cache: "no-cache" });
64+
const res = await fetch(u, { cache: "no-cache", signal: controller.signal });
7565
if (res.ok) await cache.put(u, res);
7666
} catch {
77-
// individual file failed; keep going
67+
// individual file failed or timed out; keep going
68+
} finally {
69+
clearTimeout(timer);
7870
}
7971
};
80-
for (let i = 0; i < jobs.length; i += BATCH) {
81-
await Promise.all(
82-
jobs.slice(i, i + BATCH).map(({ u, cache }) => fetchInto(u, cache))
83-
);
84-
onProgress(Math.min(i + BATCH, jobs.length), jobs.length);
72+
for (let i = 0; i < urls.length; i += BATCH) {
73+
await Promise.all(urls.slice(i, i + BATCH).map(fetchInto));
74+
onProgress(Math.min(i + BATCH, urls.length), urls.length);
8575
}
8676
return countCached(urls);
8777
}
8878

8979
export async function clearOffline(): Promise<void> {
90-
await Promise.all([caches.delete(RUNTIME_CACHE), caches.delete(PAGES_CACHE)]);
80+
await caches.delete(RUNTIME_CACHE);
9181
}
9282

9383
export function cachesSupported(): boolean {

0 commit comments

Comments
 (0)