Skip to content

feat(web): adopt next.js 16 cache components#54

Merged
bolin8017 merged 1 commit into
mainfrom
feat/next16-cache-components
Apr 18, 2026
Merged

feat(web): adopt next.js 16 cache components#54
bolin8017 merged 1 commit into
mainfrom
feat/next16-cache-components

Conversation

@bolin8017
Copy link
Copy Markdown
Owner

Summary

  • Enables cacheComponents: true and migrates the whole app to Next.js 16 Partial Prerendering: static shells + cached data + dynamic Suspense regions in every route.
  • Public pages now serve a cached static shell with per-user dynamic regions (solved checkmarks, progress bars) streaming in — SEO-critical problem and list content is rendered instantly.
  • Admin mutations (deleteProblem, flagForRegeneration, unflagRegeneration) call revalidateTag(..., 'max') for tag-scoped cache invalidation, replacing the hourly ISR window — admin edits propagate in seconds.
  • The earlier reverted attempt (documented in docs/deferred-improvements.md) failed because cacheComponents: true requires every runtime data access (cookies, headers, awaited params/searchParams, Supabase auth) to sit inside a <Suspense> boundary; that's what this PR methodically fixes across ~20 files.

What changed structurally

  • Config: cacheComponents: true in apps/web/next.config.ts.
  • Layouts: root (app/layout.tsx), admin, and (auth) now have a synchronous static chrome shell. Auth/header reads live in a Suspense-wrapped async child. Redirect semantics unchanged — proxy.ts still does the primary gate.
  • Pages: every page that awaits params/searchParams or reads cookies has a sync default export that wraps an async PageBody child in <Suspense>. Covers all 4 public pages, 7 admin pages, 6 auth pages, landing, login.
  • Public pages: shared data fetches converted from react.cache to 'use cache' + cacheLife('hours') + cacheTag(...). Cached helpers: getProblemBySlug, getListBySlug, getListProblems, getFilteredProblems, getFilteredLists, getSitemapData.
  • Cache tag vocabulary: problems, problem:<slug>, lists, list:<slug>, list:<id>:problems.
  • Admin mutations: _admin-helpers.ts gains a tags: string[] option; affected actions pass the right tags.
  • API routes: dynamic = 'force-dynamic' removed from api/cron/push and api/health (incompatible with cacheComponents; handlers default to dynamic anyway).
  • Request-scoped time: admin pages using new Date()/Date.now() for query construction now await connection() first.

Test plan

  • pnpm build: all 32 routes compile. 18 are Partial Prerender (◐), the rest static (○) or API-dynamic (ƒ).
  • pnpm --filter @caffecode/shared test: 186 passed.
  • pnpm --filter @caffecode/web test: 571 passed. Added revalidateTag to the next/cache mock; exposed LoginPageBody as a named export for direct test invocation.
  • tsc --noEmit for shared + web: clean.
  • pnpm --filter @caffecode/web lint: clean.

Follow-up

  • After merge, monitor /admin/content edits → confirm public /problems/[slug] reflects changes within seconds (no longer bounded by the 1h ISR).
  • The three deferred-improvements items from the 2026-04-17 review are now resolved: Cache Components done (this PR), Node 24 + pnpm 10 done (PR chore: upgrade to node 24 lts and pnpm 10 #53), Branded channel-identifier types decided skipped (doc updated).

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 `<Suspense>`. 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:<slug>`, `lists`, `list:<slug>`,
  `list:<id>: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.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
caffecode Ready Ready Preview, Comment Apr 18, 2026 10:02am

@github-actions
Copy link
Copy Markdown

📊 Coverage Report

Package Statements Branches Functions Lines
shared 92.22% ❌ (≥95%) 87.74% ❌ (≥90%) 96.87% ✅ (≥95%) 91.88% ❌ (≥95%)
web 94.04% ✅ (≥90%) 89.3% ✅ (≥85%) 96.52% ✅ (≥90%) 94.76% ✅ (≥90%)

🤖 Generated by CI · thresholds enforced via vitest

@bolin8017 bolin8017 merged commit 71f0d37 into main Apr 18, 2026
3 checks passed
@bolin8017 bolin8017 deleted the feat/next16-cache-components branch April 18, 2026 10:07
bolin8017 added a commit that referenced this pull request Apr 18, 2026
Audit pass over every .md after merging 17 PRs (#38-#54) in the
review cycle. Nothing behavioural changed; this PR just retires claims
that no longer match the repo.

Corrections:

- README.md: bumped prerequisite from Node 22+/pnpm 9 to Node 22+/pnpm
  10 (CI and Vercel pinned to Node 24 via .nvmrc). Updated test counts
  185 -> 186 (shared) and 566 -> 571 (web) for a new TS total of 757;
  matches the counts verified at the end of the Cache Components PR.
- CLAUDE.md: same test count update; replaced "Web + Worker" in the
  architecture + deployment tables with "Web (includes cron API route)"
  since apps/worker/ was deleted in PR #33 and the push pipeline now
  runs as /api/cron/push inside the web app. Dropped `worker` from the
  scope enum. Added a short Cache Components note pointing at the rule
  file instead of duplicating the pattern here.
- .claude/rules/git-conventions.md: same scope enum fix, with a note
  explaining why `worker` was retired. Replaced the `fix(worker): ...`
  example with `fix(web): ...`.
- .claude/rules/deployment.md: renamed "Worker Cron" section to "Cron
  Entry" (matches push-pipeline.md terminology), rewrote the deploy
  step and the Web+Worker cloud-services entry, and added Upstash
  Redis as an optional service now that PR #49 uses it for the
  cross-instance webhook rate limiter.
- .claude/rules/notifications.md: removed the stale
  `apps/worker/src/channels/**` glob; clarified that the email
  renderer lives in packages/shared.
- docs/staging-setup.md: every "Web + Worker" phrasing updated to
  reflect the single Vercel deployment that now hosts the cron route.

No production references remain to `apps/worker/`, `pnpm@9.15.0`, or
outdated test counts.
bolin8017 added a commit that referenced this pull request Apr 18, 2026
## Summary

Audit pass over every `.md` after merging 17 PRs (#38#54) in the review
cycle. Nothing behavioural changed; just retire claims that no longer
match the repo.

## Corrections

- **`README.md`**: Node 22 → Node 22+ with CI/Vercel on Node 24 (via
`.nvmrc`); pnpm 9 → pnpm 10.33.0. Test counts: shared 185 → 186, web 566
→ 571, TS total 751 → 757.
- **`CLAUDE.md`**: same test count update; "Web + Worker" → "Web
(includes cron API route)" everywhere (apps/worker/ deleted in PR #33 —
push pipeline now runs as `/api/cron/push` inside the web app). Dropped
`worker` from the commit scope enum. Added a short Cache Components note
pointing at `.claude/rules/web-patterns.md` instead of duplicating the
pattern.
- **`.claude/rules/git-conventions.md`**: same scope enum fix with
explanation. Replaced `fix(worker): ...` example with `fix(web): ...`.
- **`.claude/rules/deployment.md`**: "Worker Cron" section → "Cron
Entry" (matches push-pipeline.md terminology). Added Upstash Redis as an
optional cloud service now that PR #49 uses it for the cross-instance
webhook rate limiter.
- **`.claude/rules/notifications.md`**: removed the stale
`apps/worker/src/channels/**` path glob; clarified that the email
renderer lives in `packages/shared`.
- **`docs/staging-setup.md`**: every "Web + Worker" phrasing updated to
reflect the single Vercel deployment.

## Test plan

- `grep` sweep confirms no remaining references to `apps/worker/`,
`pnpm@9.15.0`, `751 TS`, `185 tests`, `566 tests`, `Node.js 22+`
(without the updated Node 24 context), or "Web + Worker" as a claim
about current state.
- Only remaining mentions of `apps/worker/` are in explanatory sentences
describing the removal (CLAUDE.md, deployment.md, git-conventions.md).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant