From 33087cc5b31a3290e5ec1a16d5d8dcdb41cc4326 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Thu, 16 Apr 2026 20:56:26 +0800 Subject: [PATCH] Make list pages fully dynamic; cache only at query level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker production build runs without DATABASE_URL, so any page Next tries to prerender at build (force-static / revalidate without a dynamic segment) fails on the Prisma call. Homepage, genre page, and sitemap previously had page-level revalidate, forcing prerender. Add force-dynamic to each and move the revalidation inside an unstable_cache wrapper around the Prisma query — result still cached 5m / 1h at runtime, but build no longer touches the DB. Detail page (/manga/[slug]) keeps page-level revalidate since its dynamic segment without generateStaticParams was never prerendered at build anyway. Update CLAUDE.md caching section to reflect the new strategy. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 88 ++++++++++++++++++++++++++++++++++++++++------ app/genre/page.tsx | 20 +++++++---- app/page.tsx | 20 +++++++---- app/sitemap.ts | 18 +++++++--- 4 files changed, 118 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ebf6c35..02db7b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,28 +41,58 @@ simple reader would hit. Key invariants: Python scraper writes them on insert; the backfill script covers older rows. With dims=0, the reader falls back to 3/4 and layout shifts on image load. +- Inside each placeholder div the `` is `absolute inset-0 w-full + h-full object-contain` — this is load-bearing. Natural-flow (`h-auto`) + lets subpixel aspect mismatch between DB dims and actual file dims + overflow the div, causing overlap with the next page. Absolute-pinned + + object-contain clamps the img to div dimensions no matter what. - Initial chapter meta (dims skeleton) is fetched server-side in `app/manga/[slug]/[chapter]/page.tsx` and passed as a prop. Later - chapters' meta is lazy-fetched via `/api/chapters/[id]/meta`. -- Image URLs (signed, short-TTL) come in batches via `/api/pages` triggered - by an `IntersectionObserver` with 1200 px margin. A separate - intersecting-pages `Map` is maintained for the scroll tick so the topmost - visible page can be found in O(k) without walking every placeholder. + chapters' meta is lazy-fetched via `/api/chapters/[id]/meta`, triggered + by an `IntersectionObserver` with 1200 px margin when user enters the + last `PREFETCH_NEXT_AT` pages of a chapter. +- Image URLs are **not signed** — served direct from the custom domain. + Fetched via `/api/pages` in fixed chunks of `IMAGE_CHUNK_SIZE = 5`. + Trigger runs in a `useEffect` on `[currentPageNum, currentChapterNum, + images, chapterMetas]`: fetches forward chunk when `currentPage + + PREFETCH_LEAD (3) >= maxCached`, backward chunk when `currentPage - + PREFETCH_LEAD <= minCached`, and next chapter's first chunk when + `currentPage + PREFETCH_LEAD >= meta.length`. `forceFetchPage` + (`limit=1`) is a viewport-observer fallback for pages that enter view + without cached URL (onError retry, unhandled gap). +- A separate intersecting-pages `Map` is maintained for the scroll tick + so the topmost visible page can be found in O(k) without walking every + placeholder. - Continuous multi-chapter reading auto-appends the next chapter when the - user gets within `PREFETCH_NEXT_AT = 3` pages of the end. + user gets within `PREFETCH_NEXT_AT = 3` pages of the end. Only chapters + `>= startChapterNumber` render (scrolling **up** past the start doesn't + load prior chapters — use drawer or double-tap-left). Deferred bidi- + infinite-scroll plan is in the user-memory system. +- Image cache pruning runs on `[currentChapterNum, currentPageNum]`. + `keep` set is `{current, current-1 if in first KEEP_PREV_CHAPTER_PAGES, + current+1 if in last KEEP_PREV_CHAPTER_PAGES}`. Must keep current+1 + conditionally so a backward scroll from a just-entered chapter doesn't + immediately drop the images the user is about to re-enter. - URL syncs to the chapter currently in viewport via `window.history.replaceState`. `prevChapter` / `nextChapter` for double-tap nav are derived from `currentChapterNum`, **not** from the URL or props. +- `window.history.scrollRestoration = "manual"` is set in the resume + `useLayoutEffect`. Without this, refreshing while deep in an auto- + appended chapter has the browser restore a `scrollY` that references a + (now gone) taller document, clamping to `scrollHeight` and landing + near the bottom. - All Links into the reader use `scroll={false}`, and the double-tap `router.push(..., { scroll: false })`. This is load-bearing — without it, App Router's default scroll-to-top clobbers the `useLayoutEffect` that restores the resume page. - Reading progress is persisted to `localStorage` under the key - `sunnymh:last-read:` as JSON `{chapter, page}`. `readProgress` - in `components/ReadingProgressButton.tsx` also accepts the **legacy - bare-number format** (just a chapter number string) for backward - compatibility — preserve this when touching the storage format. + `sunnymh:last-read:` as JSON `{chapter, page, ratio}` (ratio = + fractional scroll offset inside the current page). `readProgress` in + `components/ReadingProgressButton.tsx` also accepts the **legacy + bare-number format** (just a chapter number string) AND the pre-ratio + `{chapter, page}` shape — preserve both when touching the storage + format. ### Immersive reader route @@ -98,7 +128,8 @@ nav being present needs to account for this. - `app/manga/[slug]/[chapter]/page.tsx` — reader. Issues both required queries (manga+chapters, initial chapter page meta) in parallel via `Promise.all`. -- `app/api/pages/route.ts` — batched `{number, imageUrl}` with signed URLs. +- `app/api/pages/route.ts` — batched `{number, imageUrl}` (raw custom- + domain URLs, not signed). - `app/api/chapters/[chapterId]/meta/route.ts` — `[{number, width, height}]` for lazy next-chapter prefetch. - `app/api/search/route.ts` — case-insensitive title search. @@ -107,6 +138,41 @@ nav being present needs to account for this. - `app/sitemap.ts` — auto-generated `/sitemap.xml`. Each page uses `generateMetadata()` for per-route title/description. +### Caching strategy + +All DB-querying pages are `export const dynamic = "force-dynamic"` +with their Prisma queries wrapped in `unstable_cache`. **Pages do not +page-level ISR / static-prerender** — the Docker production build +runs without `DATABASE_URL` in the build env, so any page Next tries +to prerender at build time would fail on the DB connection. Keeping +everything dynamic sidesteps this while `unstable_cache` around the +query still dedupes DB hits across runtime requests. + +Cache keys + TTLs: + +- `home-manga-list` — `app/page.tsx` (5 min) +- `genre-manga-list` — `app/genre/page.tsx` (5 min) +- `sitemap-manga-list` — `app/sitemap.ts` (1 hour) +- `reader-manga` — reader's manga+chapters query (5 min) +- `reader-chapter-meta` — reader's page-dim query (1 hour — chapter + contents are immutable) +- `search-manga` — `app/search/page.tsx`, keyed on query string (60 s) +- `app/manga/[slug]/page.tsx` — has `revalidate = 300` directly (it's a + dynamic segment without `generateStaticParams`, so Next caches the + rendered result per-URL at runtime without prerendering at build). + +**No revalidateTag hook exists.** The scraper (`../manga-dl/manga.py`) +writes to Postgres directly and has no callback into this app. +Consequence: new chapters / manga appear up to 5 min after they land +in the DB. Accepted trade-off for now; wire a `POST /api/revalidate` +endpoint if that window becomes too long. +- **Images**: no application-layer cache — Cloudflare edge cache handles + it. Bucket is private at the R2 level; only exposed via the custom + domain `images.04080616.xyz` which is fronted by a **WAF Custom Rule** + blocking requests whose `http.referer` is non-empty and doesn't + contain `04080616.xyz` / `localhost` / `10.8.0.`. Hotlinking from + other sites 403s; direct `` and RSS / no-referer access work. + ### Ingestion is a sibling Python repo New manga and chapters are created by `../manga-dl/manga.py`, not by this diff --git a/app/genre/page.tsx b/app/genre/page.tsx index 033b691..3bdb84f 100644 --- a/app/genre/page.tsx +++ b/app/genre/page.tsx @@ -1,20 +1,28 @@ +import { unstable_cache } from "next/cache"; import { prisma } from "@/lib/db"; import { collectGenres } from "@/lib/genres"; import { GenreTabs } from "@/components/GenreTabs"; import type { Metadata } from "next"; -export const revalidate = 300; +export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Genres", }; +const getGenreManga = unstable_cache( + async () => + prisma.manga.findMany({ + where: { status: "PUBLISHED" }, + orderBy: { title: "asc" }, + include: { _count: { select: { chapters: true } } }, + }), + ["genre-manga-list"], + { revalidate: 300 } +); + export default async function GenrePage() { - const manga = await prisma.manga.findMany({ - where: { status: "PUBLISHED" }, - orderBy: { title: "asc" }, - include: { _count: { select: { chapters: true } } }, - }); + const manga = await getGenreManga(); const genres = collectGenres(manga); diff --git a/app/page.tsx b/app/page.tsx index 8579e78..11453d0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,16 +1,24 @@ +import { unstable_cache } from "next/cache"; import { prisma } from "@/lib/db"; import { collectGenres } from "@/lib/genres"; import { TrendingCarousel } from "@/components/TrendingCarousel"; import { GenreTabs } from "@/components/GenreTabs"; -export const revalidate = 300; +export const dynamic = "force-dynamic"; + +const getPublishedManga = unstable_cache( + async () => + prisma.manga.findMany({ + where: { status: "PUBLISHED" }, + orderBy: { updatedAt: "desc" }, + include: { _count: { select: { chapters: true } } }, + }), + ["home-manga-list"], + { revalidate: 300 } +); export default async function Home() { - const manga = await prisma.manga.findMany({ - where: { status: "PUBLISHED" }, - orderBy: { updatedAt: "desc" }, - include: { _count: { select: { chapters: true } } }, - }); + const manga = await getPublishedManga(); const trending = manga.slice(0, 10); const genres = collectGenres(manga); diff --git a/app/sitemap.ts b/app/sitemap.ts index 6156d52..c5e5f16 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,13 +1,21 @@ import type { MetadataRoute } from "next"; +import { unstable_cache } from "next/cache"; import { prisma } from "@/lib/db"; -export const revalidate = 3600; +export const dynamic = "force-dynamic"; + +const getSitemapManga = unstable_cache( + async () => + prisma.manga.findMany({ + where: { status: "PUBLISHED" }, + select: { slug: true, updatedAt: true }, + }), + ["sitemap-manga-list"], + { revalidate: 3600 } +); export default async function sitemap(): Promise { - const manga = await prisma.manga.findMany({ - where: { status: "PUBLISHED" }, - select: { slug: true, updatedAt: true }, - }); + const manga = await getSitemapManga(); const mangaEntries: MetadataRoute.Sitemap = manga.map((m) => ({ url: `https://www.04080616.xyz/manga/${m.slug}`,