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:
parent
0ccb9debbb
commit
33087cc5b3
88
CLAUDE.md
88
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 `<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
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
20
app/page.tsx
20
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);
|
||||
|
||||
@ -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}`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user