diff --git a/.claude/agent-memory/debugger/MEMORY.md b/.claude/agent-memory/debugger/MEMORY.md new file mode 100644 index 0000000..ab768f2 --- /dev/null +++ b/.claude/agent-memory/debugger/MEMORY.md @@ -0,0 +1 @@ +- [Windows ISR case collision](windows-isr-case-collision.md) — local next start case-folds cache keys; verify redirect/dynamicParams with next dev or a Vercel preview, not a Windows production build. diff --git a/.claude/agent-memory/debugger/windows-isr-case-collision.md b/.claude/agent-memory/debugger/windows-isr-case-collision.md new file mode 100644 index 0000000..4db4797 --- /dev/null +++ b/.claude/agent-memory/debugger/windows-isr-case-collision.md @@ -0,0 +1,39 @@ +--- +name: windows-isr-case-collision +description: next start on this Windows/NTFS box corrupts ISR cache for case-variant dynamic-segment redirects — do not trust local production-build smoke tests for this +metadata: + type: project +--- + +Proven 2026-09-06 while investigating `feat/game-region-path-segment` +(`src/app/game/[region]/page.js`): this machine's NTFS filesystem is +case-insensitive. When a Next 16 App Router page under `generateStaticParams` ++ default `dynamicParams: true` gets an on-demand request whose dynamic +segment differs only in case from an already-prebuilt param (e.g. +`/game/la` vs. the prebuilt `/game/LA`), the ISR disk cache lookup +case-folds and serves the prebuilt file directly — bypassing the page's own +logic entirely (here, a lowercase-to-canonical `redirect()`). Worse, the +background revalidation this triggers then overwrites the *canonical* +page's cache file with a cached `redirect()` response that is missing its +`Location` header, permanently breaking the canonical URL for that server +process (confirmed: `200` on first case-variant hit, then `307` with no +`Location` header on both casings afterward, `ETag` identical). + +**Why:** filesystem-level case folding is Windows/NTFS-specific; Vercel +(Linux, its own cache-key store) should not have this collision, so it did +not block shipping — but any redirect/dynamicParams acceptance criterion +tested via `npm run build:check && next start` **on this machine** is +unreliable and order-dependent, not a real signal. + +**Resolved in the code:** the region route no longer redirects at all — slugs +are lowercase and any casing serves directly — so there is no cached redirect +on a prerendered route to corrupt. The verification guidance below still +applies to any future route that does redirect. + +**How to apply:** when a task asks to verify redirect or dynamicParams +behavior for a Next.js App Router dynamic segment locally, either use `next +dev` (no ISR cache, always re-executes) or verify against an actual Vercel +preview / Linux CI, not a local Windows `next start`. If a "smoke test looks +broken after a build" surprise recurs for a route with case-variant dynamic +segments, check this first before assuming a code regression. Full writeup: +`plans/reports/debugger-260906-1523-region-path-runtime-edges.md`. diff --git a/docs/project-structure.md b/docs/project-structure.md index b7db2fa..cb4dd7a 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -28,10 +28,14 @@ Next.js 16 App Router structure: - `favicon.ico` - Site favicon #### Game Pages +- `not-found.js` - The app-wide 404 for any unmatched path +- `components/NotFoundPanel.js` - Shared body of both 404s - `game/[region]/page.js` - The game screen for one region (`/game/tphcm`). Server Component: validates the slug, prerenders one page per region, 404s an unknown one -- `game/[region]/not-found.js` - The 404 for an unknown region code +- `game/[region]/not-found.js` - The 404 for an unknown region code. A client + component only so it can re-apply the theme: a thrown `notFound()` is served + from Next's error shell, which carries no pre-paint theme script - `game/page.js` - Redirects the legacy `?region=` / `?location=` links to `/game/{slug}`; a region-less `/game` goes to the country round - `credits/page.js` - Data sources, licenses, and open-source credits diff --git a/src/app/components/NotFoundPanel.js b/src/app/components/NotFoundPanel.js new file mode 100644 index 0000000..e9652d6 --- /dev/null +++ b/src/app/components/NotFoundPanel.js @@ -0,0 +1,37 @@ +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; + +/** + * The shared body of every 404 in the app. + * + * Both not-found routes say the same three things -- what is missing, why it + * probably happened, and the one way out -- so the layout, spacing and the + * heading level live here and only the wording differs. + * + * A real
: a not-found route owns the whole document, + * unlike GameClient's in-place error panel, which sits inside a page that + * already has its own heading. + * @param {Object} props + * @param {string} props.title Short statement of what is missing. + * @param {React.ReactNode} props.children The explanation beneath it. + * @param {string} props.actionLabel Text for the single action. + * @param {string} props.actionHref Where that action goes. + */ +export default function NotFoundPanel({ title, children, actionLabel, actionHref }) { + return ( +
{children}
+ {/* One action, deliberately. A "try again" on a deterministically + invalid URL is a button guaranteed to reproduce the same page. */} + +: a not-found page owns the whole - document, unlike GameClient's in-place error panel, which sits - inside a page that already has its own h1. */} -
- That link points at a region that doesn't exist. It may have been - mistyped or cut short. -
- -