Make list pages fully dynamic; cache only at query level

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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-04-16 20:56:26 +08:00
parent 0ccb9debbb
commit 33087cc5b3
4 changed files with 118 additions and 28 deletions

View File

@ -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 `<img>` 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:<slug>` 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:<slug>` 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 `<img src>` 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

View File

@ -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);

View File

@ -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);

View File

@ -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<MetadataRoute.Sitemap> {
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}`,