From 20ce12208f355e06d63ff8857a1d4d069f3173a4 Mon Sep 17 00:00:00 2001 From: Po-Lin Lai Date: Sat, 18 Apr 2026 18:01:05 +0800 Subject: [PATCH] feat(web): adopt next.js 16 cache components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable Partial Prerendering across every route. Cached shell HTML is produced at build time; the dynamic regions — auth state, per-user progress, admin data — stream in from a Suspense boundary at request time. Tag-based invalidation replaces the hourly ISR window so admin content edits propagate to public pages in seconds instead of up to an hour. The migration is wide because `cacheComponents: true` enforces that every uncached runtime data access (cookies, headers, awaited params/ searchParams, Supabase auth/queries that read cookies) sits inside a Suspense boundary during static prerender. One earlier attempt was reverted for exactly this reason; the fix is the mechanical extraction done in this PR. Structural changes: - next.config.ts: `cacheComponents: true`. - Layouts: root + admin + (auth) now have a static chrome shell plus a Suspense-wrapped async child that reads `headers()` / does the auth check. Redirect semantics unchanged — proxy.ts is still the primary gate; layout Suspense is the belt-and-suspenders path. - Pages: every page that awaits params/searchParams or reads cookies now has a sync default export wrapping an async `PageBody` child in ``. Applies to all 4 public pages, 7 admin pages, 6 auth pages, the landing page, and the login page. - Public pages: static data fetches (`getProblemBySlug`, `getListBySlug`, `getListProblems`, `getFilteredProblems`, `getFilteredLists`, sitemap data) converted from `react.cache` to `'use cache'` + `cacheLife('hours')` + `cacheTag(...)`. Tags use `problems`, `problem:`, `lists`, `list:`, `list::problems` so admin mutations can target precisely. - Admin mutations: `_admin-helpers.ts` gains an optional `tags` array. `deleteProblem` revalidates `['problems', 'lists']`; `flag`/`unflagRegeneration` revalidate `['problems']`. Pair with existing `revalidatePath` so admin pages still re-render in-place. - API routes: `dynamic = 'force-dynamic'` removed from `api/cron/push` and `api/health` — incompatible with cacheComponents; route handlers default to dynamic anyway. - Request-scoped time: admin pages that use `new Date()`/`Date.now()` to construct queries now `await connection()` first (Next.js requires this signal so the value isn't frozen into the prerender). - Page-level `revalidate = 3600` + `dynamicParams = true` removed from the four public pages; those behaviours are now governed by the cached helpers' `cacheLife` + `cacheTag`. Testing: - Added `revalidateTag` to the `next/cache` mock in admin-action tests so `tags: [...]` paths don't fail. - Exposed `LoginPageBody` as a named export so the existing direct- invoke tests can keep exercising the async body. - `pnpm build`: all 32 routes compile; 18 are Partial Prerender, the rest static or API-dynamic. - `pnpm test`: 186 shared + 571 web all green. - `tsc --noEmit`, `eslint`: clean. Docs: - `.claude/rules/web-patterns.md`: new Cache Components section documenting the page pattern, `use cache` helpers, cacheTag vocabulary, and the `await connection()` requirement. - `.claude/rules/push-pipeline.md`: cron route no longer mandates `force-dynamic`. - `docs/deferred-improvements.md`: Cache Components section removed (done); Branded channel-identifier types rewritten as an explicit "decided to skip" with revisit triggers. --- .claude/rules/push-pipeline.md | 3 +- .claude/rules/web-patterns.md | 12 ++- apps/web/__tests__/login-page.test.ts | 10 +- apps/web/app/(admin)/admin/channels/page.tsx | 17 +++- apps/web/app/(admin)/admin/content/page.tsx | 15 ++- apps/web/app/(admin)/admin/layout.tsx | 44 ++++++--- apps/web/app/(admin)/admin/lists/page.tsx | 11 ++- apps/web/app/(admin)/admin/page.tsx | 13 ++- apps/web/app/(admin)/admin/problems/page.tsx | 15 ++- apps/web/app/(admin)/admin/push/page.tsx | 13 ++- apps/web/app/(admin)/admin/users/page.tsx | 15 ++- apps/web/app/(auth)/dashboard/page.tsx | 13 ++- apps/web/app/(auth)/garden/page.tsx | 11 ++- apps/web/app/(auth)/layout.tsx | 21 +++-- apps/web/app/(auth)/onboarding/page.tsx | 11 ++- apps/web/app/(auth)/settings/account/page.tsx | 17 +++- .../web/app/(auth)/settings/learning/page.tsx | 11 ++- apps/web/app/(auth)/settings/page.tsx | 11 ++- apps/web/app/(public)/lists/[slug]/page.tsx | 61 +++++++----- apps/web/app/(public)/lists/page.tsx | 50 ++++++---- .../web/app/(public)/problems/[slug]/page.tsx | 24 +++-- apps/web/app/(public)/problems/page.tsx | 60 ++++++++---- apps/web/app/api/cron/push/route.ts | 1 - apps/web/app/api/health/route.ts | 2 - apps/web/app/layout.tsx | 38 +++++--- apps/web/app/login/page.tsx | 15 ++- apps/web/app/page.tsx | 83 ++++++++++------ apps/web/app/sitemap.ts | 16 +++- apps/web/lib/__tests__/admin-actions.test.ts | 2 +- apps/web/lib/actions/_admin-helpers.ts | 5 +- apps/web/lib/actions/admin.ts | 21 ++++- apps/web/next.config.ts | 1 + docs/deferred-improvements.md | 94 +++---------------- pnpm-lock.yaml | 51 ++-------- 34 files changed, 498 insertions(+), 289 deletions(-) diff --git a/.claude/rules/push-pipeline.md b/.claude/rules/push-pipeline.md index 83484d0..ee6f341 100644 --- a/.claude/rules/push-pipeline.md +++ b/.claude/rules/push-pipeline.md @@ -23,7 +23,8 @@ The push pipeline lives in `packages/shared/src/push/`. It is invoked by `apps/w ## Cron Entry (apps/web/app/api/cron/push/route.ts) -- `export const dynamic = 'force-dynamic'` + `export const maxDuration = 300` +- `export const maxDuration = 300` (Vercel function timeout) +- No `dynamic = 'force-dynamic'` — incompatible with `cacheComponents: true`; route handlers default to dynamic. - Auth: `Authorization: Bearer ${CRON_SECRET}` verified with `timingSafeEqual` (see `isValidCronSecret`) - 10-minute overlap guard: skips if another run completed in the last 10 minutes - Calls `buildPushJobs(supabase, channelRegistry, dispatchLimit)` and `recordPushRun` diff --git a/.claude/rules/web-patterns.md b/.claude/rules/web-patterns.md index 6739d2f..4173fc1 100644 --- a/.claude/rules/web-patterns.md +++ b/.claude/rules/web-patterns.md @@ -17,6 +17,16 @@ paths: - **x-user-profile header**: `proxy.ts` queries user profile once -> sets header -> `layout.tsx` reads it -> passes to `