Skip to content

Commit 43f53f1

Browse files
committed
fix offline navigation breaking to weapon/armor/monster/decoration pages
Next.js's client router falls back to a full browser navigation when a route's RSC payload isn't cached, and while genuinely offline that navigation gets hard-reset to "/" by Next's own client bootstrap even when the service worker serves valid cached content. Since we deliberately don't precache the ~3800 individual item pages, this made offline browsing effectively unusable beyond whatever page was open when connectivity dropped. Intercept link clicks while offline (before Next's <Link> handler runs) and render the destination in-place from the cached data bundles instead of asking the router to navigate at all, keeping the address bar and back/forward in sync via history.pushState. List pages (weapon type trees, armor slot/rank, monster categories, decorations) are split into thin server pages + reusable client content components so the same components back both real navigation and this in-place rendering. Also fixes a staleness bug in client-data.ts where switching between sibling items backed by the same bundle didn't re-render with the new item.
1 parent f0df329 commit 43f53f1

25 files changed

Lines changed: 814 additions & 368 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ content/decorations/**/*.md
2121
/public/sw.js
2222
/public/workbox-*.js
2323
/public/swe-worker-*.js
24+
/public/fallback-*.js
2425

2526
# vim temporary files
2627
**/.*.sw*

‎next.config.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ export default withPWA({
8181
aggressiveFrontEndNavCaching: true,
8282
reloadOnOnline: true,
8383
disable: process.env.NODE_ENV === "development",
84+
// When a navigation (full page load, or the hard-navigation Next.js falls
85+
// back to when a client-side RSC fetch fails offline) misses every cache,
86+
// serve this precached shell instead of a network error. It reads the
87+
// *actual* requested URL client-side and renders the right weapon/armor/
88+
// monster/decoration detail view from the already-cached data bundles —
89+
// see src/app/offline. This is what lets any of the ~3800 detail pages
90+
// work offline without precaching every single one of them.
91+
fallbacks: {
92+
document: "/offline/",
93+
},
8494
workboxOptions: {
8595
disableDevLogs: true,
8696
cacheId,

‎src/app/HomeContent.tsx‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Link from "next/link";
2+
import styles from "./page.module.scss";
3+
4+
export default function HomeContent() {
5+
return (
6+
<main className={styles.main}>
7+
<Link className={styles.title} href="/">
8+
<img src="/images/blacksmith.png" alt="Blacksmith" />
9+
</Link>
10+
11+
<hr className="border" />
12+
13+
<div className={styles.menuWrapper}>
14+
<div className={styles.menu}>
15+
<ul>
16+
<li>
17+
<Link href="/blacksmith/">Weapons</Link>
18+
</li>
19+
<li>
20+
<Link href="/armorsmith/">Armor</Link>
21+
</li>
22+
<li>
23+
<Link href="/decorations/">Decorations</Link>
24+
</li>
25+
<li>
26+
<Link href="/monsters/">Monsters</Link>
27+
</li>
28+
<li>
29+
<Link href="/hunter/">Hunter Profile</Link>
30+
</li>
31+
<li>
32+
<Link href="/options/">Options</Link>
33+
</li>
34+
</ul>
35+
</div>
36+
</div>
37+
</main>
38+
);
39+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import Link from "next/link";
2+
import { ARMOR_SLOTS, ARMOR_RANKS } from "@/lib/constants";
3+
import Card from "@/components/Card";
4+
import Icon from "@/components/Icon";
5+
import listStyles from "@/styles/list-page.module.scss";
6+
7+
function formatLabel(s: string): string {
8+
return s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
9+
}
10+
11+
export default function ArmorsmithListContent() {
12+
return (
13+
<>
14+
<nav className="flex w-full items-center justify-start max-[600px]:flex-wrap max-[600px]:justify-center">
15+
<div>
16+
<Link href="/" aria-label="Back">
17+
<Icon type="arrow" nav alt="Back" />
18+
</Link>
19+
</div>
20+
<div className="ml-auto">
21+
<button
22+
className="search-trigger flex h-[52px] w-[52px] cursor-pointer items-center border-none bg-transparent p-0 text-white/60 hover:text-white [&>img]:h-full"
23+
aria-label="Search"
24+
>
25+
<img src="/images/binoculars.png" alt="Search" />
26+
</button>
27+
</div>
28+
</nav>
29+
30+
<hr className="border" />
31+
32+
<div className="flex h-full w-full flex-wrap justify-center gap-[1.3rem] overflow-auto">
33+
{ARMOR_SLOTS.map((slot) => (
34+
<div key={slot}>
35+
<Card className={listStyles.table}>
36+
<p>{formatLabel(slot)}</p>
37+
{ARMOR_RANKS.map((rank) => (
38+
<Link key={rank} href={`/armorsmith/${slot}/${rank}/`} className="list-row">
39+
<Icon type={slot} size="large" alt={slot} />
40+
<p>{formatLabel(rank)}</p>
41+
</Link>
42+
))}
43+
</Card>
44+
</div>
45+
))}
46+
</div>
47+
</>
48+
);
49+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import WeaponTreeRow from "@/components/WeaponTreeRow";
5+
import TreeScroll from "@/components/TreeScroll";
6+
import Card from "@/components/Card";
7+
import Icon from "@/components/Icon";
8+
import type { ArmorSlot, ArmorRank } from "@/lib/constants";
9+
import { useArmorTree } from "@/lib/client-data";
10+
11+
interface Props {
12+
slot: ArmorSlot;
13+
rank: ArmorRank;
14+
}
15+
16+
export default function ArmorTreeListClient({ slot, rank }: Props) {
17+
const { tree, loading } = useArmorTree(slot, rank);
18+
const basePath = `/armorsmith/${slot}/${rank}/`;
19+
20+
return (
21+
<>
22+
<nav className="flex w-full items-center justify-start max-[600px]:flex-wrap max-[600px]:justify-center">
23+
<div>
24+
<Link href="/armorsmith/" aria-label="Back">
25+
<Icon type="arrow" nav alt="Back" />
26+
</Link>
27+
</div>
28+
<div className="ml-auto">
29+
<button
30+
className="search-trigger flex h-[52px] w-[52px] cursor-pointer items-center border-none bg-transparent p-0 text-white/60 hover:text-white [&>img]:h-full"
31+
aria-label="Search"
32+
>
33+
<img src="/images/binoculars.png" alt="Search" />
34+
</button>
35+
</div>
36+
</nav>
37+
<hr className="border" />
38+
39+
<div className="split-page">
40+
<Card variant="weapon-tree">
41+
<TreeScroll treeKey={`armor-${slot}-${rank}`}>
42+
{!loading &&
43+
tree?.map((node) => (
44+
<ul key={node.slug}>
45+
<WeaponTreeRow node={node} sectionType={slot} basePath={basePath} />
46+
</ul>
47+
))}
48+
</TreeScroll>
49+
</Card>
50+
51+
<Card>
52+
<p>{loading ? "Loading…" : "Select a piece of armor from the tree."}</p>
53+
</Card>
54+
</div>
55+
</>
56+
);
57+
}
Lines changed: 2 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1-
import Link from "next/link";
21
import { notFound } from "next/navigation";
32
import { getArmorParams, getArmorTree } from "@/lib/armor";
43
import { ARMOR_SLOTS, ARMOR_RANKS, type ArmorSlot, type ArmorRank } from "@/lib/constants";
5-
import WeaponTreeRow from "@/components/WeaponTreeRow";
6-
import TreeScroll from "@/components/TreeScroll";
7-
import Card from "@/components/Card";
8-
import Icon from "@/components/Icon";
94
import type { Metadata } from "next";
5+
import ArmorTreeListClient from "./ArmorTreeListClient";
106

117
interface Props {
128
params: Promise<{ slot: string; rank: string }>;
@@ -32,42 +28,5 @@ export default async function ArmorTreePage({ params }: Props) {
3228
const tree = getArmorTree(slot as ArmorSlot, rank as ArmorRank);
3329
if (!tree) notFound();
3430

35-
const basePath = `/armorsmith/${slot}/${rank}/`;
36-
37-
return (
38-
<>
39-
<nav className="flex w-full items-center justify-start max-[600px]:flex-wrap max-[600px]:justify-center">
40-
<div>
41-
<Link href="/armorsmith/" aria-label="Back">
42-
<Icon type="arrow" nav alt="Back" />
43-
</Link>
44-
</div>
45-
<div className="ml-auto">
46-
<button
47-
className="search-trigger flex h-[52px] w-[52px] cursor-pointer items-center border-none bg-transparent p-0 text-white/60 hover:text-white [&>img]:h-full"
48-
aria-label="Search"
49-
>
50-
<img src="/images/binoculars.png" alt="Search" />
51-
</button>
52-
</div>
53-
</nav>
54-
<hr className="border" />
55-
56-
<div className="split-page">
57-
<Card variant="weapon-tree">
58-
<TreeScroll treeKey={`armor-${slot}-${rank}`}>
59-
{tree.map.map((node) => (
60-
<ul key={node.slug}>
61-
<WeaponTreeRow node={node} sectionType={slot} basePath={basePath} />
62-
</ul>
63-
))}
64-
</TreeScroll>
65-
</Card>
66-
67-
<Card>
68-
<p>Select a piece of armor from the tree.</p>
69-
</Card>
70-
</div>
71-
</>
72-
);
31+
return <ArmorTreeListClient slot={slot as ArmorSlot} rank={rank as ArmorRank} />;
7332
}

‎src/app/armorsmith/page.tsx‎

Lines changed: 2 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,10 @@
1-
import Link from "next/link";
2-
import { ARMOR_SLOTS, ARMOR_RANKS } from "@/lib/constants";
31
import type { Metadata } from "next";
4-
import Card from "@/components/Card";
5-
import Icon from "@/components/Icon";
6-
import listStyles from "@/styles/list-page.module.scss";
2+
import ArmorsmithListContent from "./ArmorsmithListContent";
73

84
export const metadata: Metadata = {
95
title: "Armor — MHFU Blacksmith",
106
};
117

12-
function formatLabel(s: string): string {
13-
return s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
14-
}
15-
168
export default function ArmorsmithPage() {
17-
return (
18-
<>
19-
<nav className="flex w-full items-center justify-start max-[600px]:flex-wrap max-[600px]:justify-center">
20-
<div>
21-
<Link href="/" aria-label="Back">
22-
<Icon type="arrow" nav alt="Back" />
23-
</Link>
24-
</div>
25-
<div className="ml-auto">
26-
<button
27-
className="search-trigger flex h-[52px] w-[52px] cursor-pointer items-center border-none bg-transparent p-0 text-white/60 hover:text-white [&>img]:h-full"
28-
aria-label="Search"
29-
>
30-
<img src="/images/binoculars.png" alt="Search" />
31-
</button>
32-
</div>
33-
</nav>
34-
35-
<hr className="border" />
36-
37-
<div className="flex h-full w-full flex-wrap justify-center gap-[1.3rem] overflow-auto">
38-
{ARMOR_SLOTS.map((slot) => (
39-
<div key={slot}>
40-
<Card className={listStyles.table}>
41-
<p>{formatLabel(slot)}</p>
42-
{ARMOR_RANKS.map((rank) => (
43-
<Link key={rank} href={`/armorsmith/${slot}/${rank}/`} className="list-row">
44-
<Icon type={slot} size="large" alt={slot} />
45-
<p>{formatLabel(rank)}</p>
46-
</Link>
47-
))}
48-
</Card>
49-
</div>
50-
))}
51-
</div>
52-
</>
53-
);
9+
return <ArmorsmithListContent />;
5410
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Link from "next/link";
2+
import { WEAPON_TYPES } from "@/lib/constants";
3+
import Card from "@/components/Card";
4+
import Icon from "@/components/Icon";
5+
import listStyles from "@/styles/list-page.module.scss";
6+
7+
function formatWeaponName(type: string): string {
8+
return type.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
9+
}
10+
11+
export default function BlacksmithListContent() {
12+
return (
13+
<>
14+
<nav className="flex w-full items-center justify-start max-[600px]:flex-wrap max-[600px]:justify-center">
15+
<div>
16+
<Link href="/" aria-label="Back">
17+
<Icon type="arrow" nav alt="Back" />
18+
</Link>
19+
</div>
20+
<div className="ml-auto">
21+
<button
22+
className="search-trigger flex h-[52px] w-[52px] cursor-pointer items-center border-none bg-transparent p-0 text-white/60 hover:text-white [&>img]:h-full"
23+
aria-label="Search"
24+
>
25+
<img src="/images/binoculars.png" alt="Search" />
26+
</button>
27+
</div>
28+
</nav>
29+
30+
<hr className="border" />
31+
32+
<div className="flex h-full w-full flex-wrap justify-center gap-[1.3rem] overflow-auto">
33+
<Card className={listStyles.table}>
34+
{WEAPON_TYPES.map((type) => {
35+
const name = formatWeaponName(type);
36+
return (
37+
<Link key={type} href={`/blacksmith/${type}/`} className="list-row">
38+
<Icon type={type} alt={name} />
39+
<p>{name}</p>
40+
</Link>
41+
);
42+
})}
43+
</Card>
44+
</div>
45+
</>
46+
);
47+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"use client";
2+
3+
import WeaponNav from "@/components/WeaponNav";
4+
import WeaponTreeRow from "@/components/WeaponTreeRow";
5+
import TreeScroll from "@/components/TreeScroll";
6+
import Card from "@/components/Card";
7+
import type { WeaponType } from "@/lib/constants";
8+
import { useWeaponTree } from "@/lib/client-data";
9+
10+
interface Props {
11+
type: WeaponType;
12+
}
13+
14+
export default function WeaponTreeListClient({ type }: Props) {
15+
const { tree, loading } = useWeaponTree(type);
16+
const basePath = `/blacksmith/${type}/`;
17+
18+
return (
19+
<>
20+
<WeaponNav activeType={type} />
21+
<hr className="border" />
22+
23+
<div className="split-page">
24+
<Card variant="weapon-tree">
25+
<TreeScroll treeKey={`weapon-${type}`}>
26+
{!loading &&
27+
tree?.map((node) => (
28+
<ul key={node.slug}>
29+
<WeaponTreeRow node={node} sectionType={type} basePath={basePath} />
30+
</ul>
31+
))}
32+
</TreeScroll>
33+
</Card>
34+
35+
<Card>
36+
<p>{loading ? "Loading…" : "Select a weapon from the tree."}</p>
37+
</Card>
38+
</div>
39+
</>
40+
);
41+
}

0 commit comments

Comments
 (0)