diff --git a/.agents/skills/next-cache-components-adoption/SKILL.md b/.agents/skills/next-cache-components-adoption/SKILL.md new file mode 100644 index 0000000..07ac8aa --- /dev/null +++ b/.agents/skills/next-cache-components-adoption/SKILL.md @@ -0,0 +1,250 @@ +--- +name: next-cache-components-adoption +description: > + Turn on Cache Components in a Next.js app and resolve the blocking routes it + surfaces. Use when the user wants to enable, adopt, or migrate to Cache + Components, flip the `cacheComponents` flag, work through a flood of + blocking-prerender / instant validation errors, run the + `cache-components-instant-false` codemod, or decide between opting routes out + with `export const instant = false` and fixing them in place. +--- + +# next-cache-components-adoption + +Enable Cache Components on an app and walk it to a passing build. This skill sequences the work; per-error recipes live in the dev overlay fix cards and the build's terminal output. The [migrating to Cache Components guide](https://nextjs.org/docs/app/guides/migrating-to-cache-components) is the canonical reference for the concepts and per-API recipes this skill applies — consult it whenever the skill steps reference a pattern (`"use cache"`, `cacheLife`, `` placement, etc.) and you want the full explanation. + +## requires + +- **App Router project.** Cache Components is an App Router feature; `cacheComponents: true` does nothing for `pages/` routes. If the project has a `pages/` or `src/pages/` tree but no `app/` or `src/app/` tree, stop and tell the user — Pages → App migration is its own project, not part of this skill. A hybrid app (both `pages/` and `app/`) is fine: the flag affects the `app/` routes; `pages/` routes are unaffected and don't need opt-outs. + +- **A resolved app directory.** Locate `next.config.{js,ts,mjs,cjs}` first: that's the project root, and an agent invoked from a subdirectory would otherwise test for `app/` against the wrong `cwd` and find nothing. Look for `app/` and `src/app/` under it, and treat every command and glob in this skill as relative to whichever one exists. If both exist, Next.js builds `app/` and never looks at `src/app/`, so its routes are shadowed and unbuilt — tell the user that and ask which tree to migrate instead of picking one. + +- **A runnable app.** The whole loop verifies against `next dev` and a browser, so the app has to boot. If it reads a database or required env at import (e.g. an `env.ts` that throws on a missing `DATABASE_URL`), confirm it actually starts — with the real environment, or local data you stand up — before step 1. Adoption can't be verified against an app that won't run. + +- **Next.js 16.3 or later.** That release is where the pieces this skill relies on land: top-level `cacheComponents`, `export const instant`, the dev-overlay instant-navigation validation warnings, and the `cache-components-instant-false` codemod. If `next --version` reports below 16.3, upgrade first: + - `npx @next/codemod@latest upgrade latest` to apply the version-to-version codemods. + - Read the relevant [version upgrade guide](https://nextjs.org/docs/app/guides/upgrading) (e.g. [Version 16](https://nextjs.org/docs/app/guides/upgrading/version-16)) for what the codemod doesn't cover. + +- **No incompatible config keys.** `cacheComponents: true` errors on any file that still exports `dynamic`, `revalidate`, or `fetchCache`. Inventory these exports before running the codemod, then follow the [migration guide's per-key sections](https://nextjs.org/docs/app/guides/migrating-to-cache-components). The guide is the source of truth for translating each value. The `cache-components-instant-false` codemod does not remove these configs. + +- **`experimental.dynamicIO` is fatal.** It was renamed to top-level `cacheComponents` and the old key now aborts before any build can run — remove it (or replace with `cacheComponents: true`) first. `experimental.useCache` is still accepted as a deprecated alias; redundant once `cacheComponents: true` is set, so remove it for clarity. + +### notes + +- **No passing baseline before the flag.** If the app already uses `"use cache"`, the pre-flag build errors with `please enable the feature flag cacheComponents`. Enabling the flag is the first thing you do (in Incremental, before the codemod; in Direct, before fixing routes) — not a thing to do _after_ getting a passing build. Note this in your starting summary so it doesn't read as a regression. + +- **Existing caches can stay.** Follow the migration guide's [`fetch` and `unstable_cache` sections](https://nextjs.org/docs/app/guides/migrating-to-cache-components#fetch-cache-options). Do not rewrite them solely to enable Cache Components. + +- **Offline docs.** Guide links have offline copies under `node_modules/next/dist/docs/` (bundled since Next.js 16.2), with the directory layout numbered for ordering (e.g. `node_modules/next/dist/docs/01-app/02-guides/migrating-to-cache-components.md`). If you can't predict the numbered prefix, `find node_modules/next/dist/docs -name '.md'` resolves it. The `/docs/messages/*` error pages are not bundled. + +- **Older versions without bundled docs.** Suggest `npx @next/codemod@latest agents-md` to the user before starting: it downloads a version-matched copy to `.next-docs/` and writes an index into `AGENTS.md` / `CLAUDE.md`. It touches files in their repo, so ask first and run it only if they want it. + +## the shape of the work + +There's one loop: walk the route tree top-down, one feature at a time, adopting each route against `next dev` + a browser. The build is a final check for each feature, not the working surface. + +The choice in step 1 is whether to opt every route out of validation first or fix routes as you go. Either way the loop is the same: + +- **With a quiet pre-step (Incremental).** Run the codemod, fix what it can't, and fully migrate routes that previously required static rendering. Other routes keep their opt-outs for follow-up PRs. +- **Without (Direct).** Enable `cacheComponents` and start the loop on whatever the build flags first. Same loop, but every fix sits on one branch until adoption is complete. + +In both, the per-route success bar is the same: **dev loop reports no errors AND `next build` passes**. Check in with the user after every feature, and suggest a commit but never make one without their confirmation. Expect to spend most of the time in the loop, not in the pre-step. + +## background + +`cacheComponents: true` requires every route to be prerenderable. A route that reads request-time data outside `` is "blocking" and fails the build. `export const instant = false` marks a route as allowed to block, which clears it in both dev and build; on a layout it covers the whole subtree during the build, but client navigations still validate each descendant segment on its own. Reads wrapped in a [`"use cache"`](https://nextjs.org/docs/app/api-reference/directives/use-cache) function count as cache boundaries, not blocking reads. + +When a fix introduces `"use cache"`, follow the Caching guide for [choosing a data-level or UI-level boundary and setting its lifetime](https://nextjs.org/docs/app/getting-started/caching#usage) and [revalidating after mutations](https://nextjs.org/docs/app/getting-started/revalidating). Use the [`use cache` cache-key reference](https://nextjs.org/docs/app/api-reference/directives/use-cache#cache-keys) when the result varies by arguments or captured values. + +Three classes of blocker come up, usually in this order: + +Whenever a fix introduces ``, follow the Streaming guide's [granular streaming pattern](https://nextjs.org/docs/app/guides/streaming#granular-streaming-with-suspense) and [guidance for preventing CLS](https://nextjs.org/docs/app/guides/streaming#cls-cumulative-layout-shift). + +1. **Request-time reads** (`cookies()`, `headers()`, `await params`, `await searchParams`). All four block when awaited at the top of a page or layout. `params` and `searchParams` often get missed because they're not framed as "request data" the way cookies and headers are. The fix is to push the read into a ``-wrapped child — and for `params`/`searchParams`, forward the promise into the child and await it there; don't `await` at the page top. +2. **Sync-IO at module/render time** (`new Date()`, `Date.now()`, `Math.random()`, `crypto.randomUUID()`). These fail the build even with `instant = false` — the opt-out doesn't suppress them. If they're in a shared layout, they block every route under it. The codemod can't fix them; after running it, use each build error and its linked documentation to identify which reported calls to translate by hand (see the [incremental pre-step](#incremental)). +3. **`"use cache"` files that read request data.** A file with a top-level `"use cache"` directive can't export `instant`; combining the two errors with `Only async functions are allowed to be exported in a "use cache" file.`, which means the directive was wrong for that route. Remove it before running the codemod. + +## working surfaces + +### finding blocking routes + +Prefer `next dev` over `next build` while you work. + +- **`next dev`** — the working surface. Visit a route; its blocking errors surface in the dev overlay with full stack traces and fix cards linking the per-error docs. Work one route at a time — errors don't accumulate in one place. The route itself still returns HTTP 200, so read the overlay (or `.next-dev.log`), not status codes. A cleared overlay is one half of calling a route clean — the other half is browser verification (see [step 2](#step-2-the-inner-loop-remove-opt-outs-one-feature-at-a-time)) and a passing build for that route. +- **`next build`** — detection only. The build is `next dev`'s authoritative check, not its replacement. Use it as the last gate on each feature in the loop (a passing build is part of the per-route success bar) and as the final verification across the whole app. In Incremental, the build also confirms the pre-step (codemod opted every route out, no shared layout still has a sync-IO blocker) before you ship that PR. Don't reach for the build instead of the dev loop while you're working a route — a passing compile doesn't tell you what ended up in the static shell and what streamed. By default the build stops at the first blocking route, so it's also poor for sizing the work. Two flags help when iterating: `--debug-build-paths` builds only the routes you name (comma-separated glob patterns of file paths relative to the project root, e.g. `--debug-build-paths="app/admin/**/page.tsx"` — not URL paths; `--debug-build-paths="app/(marketing)/about/page.tsx"` — not `/about`; `--debug-build-paths="app/admin"` matches nothing and silently builds zero routes), and `--debug-prerender` disables the early exit so the build continues past the first prerender failure, reports every blocking route, and prints a fuller stack trace that names the originating file and line. + +Every blocking error has a docs page — open it. Both the dev overlay and the build terminal print a `https://nextjs.org/docs/messages/` link with each error. That page is the canonical recipe for the fix; the inline message is a summary. Fetch the link for every distinct error you encounter, even if you think you know the pattern — the recipes evolve, and the same error class can have different correct fixes depending on what the route reads. Don't improvise from the inline message alone. (`/docs/messages/*` pages aren't bundled offline; if you have no network, fall back to the per-API guides under `node_modules/next/dist/docs/` and note the limitation when you report back.) + +### verifying each fix at runtime + +A passing build or a cleared overlay isn't proof the route actually behaves — Cache Components is a runtime concern (a static shell with streamed data). Verify after every fix, not only at the end. + +In preference order: + +1. **[`next-dev-loop`](https://github.com/vercel/next.js/tree/canary/skills/next-dev-loop) — strongly preferred.** Cross-checks `/_next/mcp` against the live browser via `agent-browser` and surfaces both compile and runtime issues in one pass. The diagnostics (React tree, suspense boundaries, console + network) are richer than poking at `next dev` by hand. + + Install it before starting the loop. Don't wait until you hit something `next dev` alone can't explain. It ships alongside this skill, so check whether it is already available first, and install it only if it is not: + + ```bash + npx skills add https://github.com/vercel/next.js/tree/canary/skills/next-dev-loop + ``` + + The skill states its required `agent-browser` version and walks you through it. + + **Requires Turbopack.** If `package.json`'s `dev` script passes `--webpack`, flag it to the user and ask whether there's a reason to stay on webpack. If not, switch to Turbopack (the Next.js 16.3+ default). If they want to keep webpack, skip this install and use the [build-only loop](#the-loop-build-only-fallback) instead. + + You don't need permission to install `next-dev-loop` itself. It's a tool, like installing a dev dependency. If a user is present, briefly tell them you're installing it for verification. In a non-interactive run (CI, dashboard, sandbox), install it without asking — "can't prompt the user" is not a reason to skip. The only legitimate skip is a real technical blocker: no network, no npm, read-only filesystem, a stated no-new-deps policy, or a webpack-only dev script. If you skip, name the specific blocker in your final report. + +2. **A browser you can drive yourself.** Playwright, `agent-browser` directly, any browser-automation tool. Use only when `next-dev-loop` is genuinely blocked. You'll miss the framework-side checks (`/_next/mcp`), so DOM assertions alone don't catch every regression — be more cautious about what you call "verified." + +3. **Build-only.** If you can't run a dev server at all, the build is your only signal. `○ (Static)` routes with no `` are fully verified by the build (nothing streamed to test). `◐ (Partial Prerender)` routes are only shell-verified — flag them when you report back. + +4. **No tooling at all.** Ask the user to run the dev server (or build) and report what they see, or hand off the milestone you've reached. + +## step 1: choose a strategy + +Ask the user, in terms of the PRs they want, not the size of the job. Never use the internal labels (Incremental, Direct) when talking to the user — those are your own scaffolding. Ask in terms of PRs and features, e.g.: _"Do you want me to first open a PR that turns on Cache Components and opts every route out of validation, then handle the actual route adoptions feature-by-feature in follow-up PRs? Or do everything on one branch?"_ Even on a tiny app, the incremental path still has value (review-sized PR, revertible, the `// TODO: Cache Components adoption` markers double as your work queue for next session). Don't pick on their behalf. + +If there's no user to ask, default to **Incremental** and document the choice. + +Honor an explicit choice in the request. If the user asks to migrate incrementally and also asks you to complete the migration, establish and verify the incremental checkpoint before continuing to the remaining routes in the same task. “Complete” sets the stopping point; it does not change the chosen strategy. + +- **Incremental** — quiet pre-step + the loop. Run the codemod to opt every page and layout out of validation, get the build passing, stop and check in with the user (see [end of the pre-step](#end-of-the-pre-step-check-in)), then enter [step 2's loop](#step-2-the-inner-loop-remove-opt-outs-one-feature-at-a-time) and ship each feature as a follow-up PR. +- **Direct** — skip the pre-step. Enable `cacheComponents` and go straight to [step 2's loop](#step-2-the-inner-loop-remove-opt-outs-one-feature-at-a-time); the build's blocking routes are the work queue. + +### incremental + +Before invoking the codemod, grep for `^export const (revalidate|dynamic|fetchCache)` across the app directory and follow the migration guide for every match. Mark routes that use `dynamic = 'force-static'` or `dynamic = 'error'` for full migration in this PR. The codemod does not remove incompatible configs. + +The codemod refuses to run on a dirty working tree. Commit or stash unrelated work first, or pass `--force` to let its edits land alongside your WIP. Common false positive: if you recently upgraded Next.js, `package.json` and the lockfile will already be dirty — commit those first. + +```bash +npx @next/codemod@latest cache-components-instant-false ./app +``` + +Pass the app directory you resolved in [requires](#requires). A wrong path is not an error: it reports `0 ok` and exits `0`, so read the file count and treat zero as a failed run, not an adopted app. + +Inserts `export const instant = false` (with a `// TODO: Cache Components adoption` comment) into every `{page,layout,default}` file under that directory, skipping files that already declare `instant` and any module marked `"use client"` or `"use server"`. Then set `cacheComponents: true`. The TODO comments are the work queue for the loop. + +If the codemod isn't available (older `@next/codemod`, sandboxed environment, offline run), reproduce it by hand: for every `{page,layout,default}.{js,jsx,ts,tsx}` in the app directory that isn't `"use client"` or `"use server"` and doesn't already declare `instant`, insert this after the imports: + +```ts +// TODO: Cache Components adoption. Refactor this route so this opt-out can be removed. +// See: https://nextjs.org/docs/app/guides/migrating-to-cache-components +export const instant = false +``` + +The codemod opts every segment out, not only the root, on purpose. Resolution is top-down, first-explicit-config-wins: the highest `instant = false` decides the whole subtree. With an opt-out on every segment, removing one segment's opt-out validates only that segment; descendants keep their own opt-outs and stay passing. If only the root were opted out, removing it would re-arm validation for the entire app at once. + +Because the highest opt-out wins, remove them top-down (root layout first, then descend). Removing a leaf's opt-out does nothing while an ancestor still holds one. + +Remove the opt-outs from the previously static routes and any shared segments that mask their validation. Resolve their errors and confirm they still prerender. A cached data call alone does not prove the route remains static. + +Next, run `next build` to surface blockers the codemod could not handle. The build is the proof, not the codemod run — a shared layout that calls `new Date()` / `Math.random()` directly still fails regardless of the opt-out (see [background](#background)). If the normal build reports a sync-IO error without locating the call, rerun that route with `next build --debug-prerender --debug-build-paths="app/path/to/page.tsx"`. For each sync-IO error it reports: + +1. **Sync-IO at module/render time.** Use the route, originating file and line, and `/docs/messages/` link in the build output to locate the error. If needed, grep the whole repo for `new Date()`, `Date.now()`, `Math.random()`, and `crypto.randomUUID()` (not only `app/**/layout.{js,jsx,ts,tsx}` — the read might live in any component imported by a layout). Do not change unreported matches. Apply the appropriate option from the linked error page, then add this comment above a temporary boundary introduced only to unblock the build: + + ```tsx + // TODO: Cache Components adoption. Added to unblock the build: remove this boundary to re-trigger the error and review the documented options. + ``` + + It shares the `TODO: Cache Components adoption` prefix with the comments the codemod writes, so the check-in grep finds both. Removing the boundary makes the error fire again with its fix cards — the same motion as removing an opt-out in the loop. + +After each fix, rerun the scoped build when available, then run `next build` again to find the next blocker. Repeat until the normal build passes. + +After the build passes, confirm every deferred route is still covered by an opt-out and no shared opt-out covers a previously static route. If the app has no previously static routes and the root layout remains deferred, confirm it got an opt-out (`grep -n "export const instant" /layout.*`). The root layout renders every route, including framework routes like `/_not-found`, so if it was missed, add `export const instant = false` to it by hand. + +Synthetic routes like `/_not-found` have no user file — when they block, fix the root layout's opt-out, not the synthetic route. Client Components (`"use client"`) get no opt-out (it's a build error to export `instant` from them), but they are not a rare blocker. The high-frequency case is a client component in the root layout's nav or header calling `usePathname()`/`useSearchParams()`: it blocks _every_ dynamic route with `blocking-prerender-client-hook`, and static routes pass (the pathname is known at prerender), which masks it until you reach a dynamic segment. It's not an ancestor-data fix — follow the [error's docs page](https://nextjs.org/docs/messages/blocking-prerender-client-hook) for the `` recipe. Only when a client route blocks on _server_ data do you fix that data in its ancestor. + +### end of the pre-step: check in + +Incremental only. The pre-step is the shippable PR. Record the passing checkpoint before starting step 2. Unless the user already asked you to continue through the full migration in the same task, stop and check in. Talk to the user in their language; don't say "Incremental" or other internal labels; talk about adoption, PRs, and what the app does now. Tell them: + +- What you did: turned on Cache Components, ran the codemod, migrated the previously static routes, fixed the remaining blockers, and confirmed the build passes. +- What changed: the previously static routes still prerender. Other pages and layouts keep a `// TODO: Cache Components adoption` opt-out. +- What to sanity-check: the previously static routes stay fully prerendered and prefetchable, and request-specific data on the deferred routes remains request-specific. +- The question: "Want to open this as its own PR before we start adopting Cache Components route by route? Or keep going on this branch?" Wait for the answer. + +If the user already asked you to complete the migration, continue after recording this checkpoint instead of asking the same question again. + +### direct + +Set `cacheComponents: true` and move to [step 2](#step-2-the-inner-loop-remove-opt-outs-one-feature-at-a-time). The build's blocking routes are the work queue. + +## step 2: the inner loop, remove opt-outs one feature at a time + +A "feature" is a single product surface — `app/settings/profile/**`, `app/posts/[slug]/**` — not a whole top-level app like `app/dashboard/**`. Finish one end-to-end before starting the next. + +Within a feature, walk top-down (layouts before pages, root layout first). Removing a layout's opt-out before its descendants exposes the layout's own blocking reads. (Direct: there are no opt-outs to remove — fix each failing route; if a hand-written opt-out on an ancestor shadows it, remove that first.) + +A passing build mid-walk doesn't mean the layout is clean. Removing a layout's opt-out while its descendant pages still have theirs keeps the build passing — each page shadows the inherited validation. The layout's actual blocking reads only surface once nothing below it shadows them. Don't call a feature done at the layout boundary. + +Use the **with-a-browser** loop unless a browser is genuinely unreachable. The [`next-dev-loop`](#verifying-each-fix-at-runtime) skill is the source of truth for what counts as "browser available" and how to install it. + +### the loop, with a browser (preferred) + +Per route: + +- Remove the opt-out (Incremental) or target the failing route (Direct). +- Reload in dev. Overlay clean? Skip to verify. Overlay still red? Fix. +- Fix — fetch the docs page linked from the error (`https://nextjs.org/docs/messages/`), apply the recipe from there. The inline overlay text is a summary; the docs page is the source of truth. +- Verify in the browser. Confirm the visible content on first paint is what you intended in the shell — not stuck on a fallback, not silently streaming everything out of an empty shell. +- Re-check siblings if the fix touched shared code (a layout, a sidebar component). A shared-shell change can fix the route you're on and break a sibling. + +### the loop, build-only (fallback) + +Used when there's no way to drive a browser — CI, sandbox, the user has no `next dev` running and you can't start one. Weaker signal: confirms the build passes and the route prerenders, but not what ended up in the static shell vs streamed. + +Per route: + +- Remove the opt-out (Incremental) or target the failing route (Direct). +- Rebuild with `--debug-build-paths app//**` (only that route) or `--debug-prerender` (full build, but past the first failure). Route passing? Move on. Still blocking? Fix. +- Fix — fetch the docs page linked from the error (`https://nextjs.org/docs/messages/`), apply the recipe from there. +- Re-check siblings if the fix touched shared code. +- Flag the route as build-only-verified when you hand the feature off. Each `◐` route still needs a browser pass before the feature is done. + +### loop notes + +- The [three blocker classes from background](#background) often get missed when fixing in place. Caching a downstream fetch (`getThing(id)`) doesn't clear an `await params` at the top of the page body — push the param promise into the ``-wrapped child. +- Ambiguous calls are user check-ins, not agent judgment. When you're not sure which fix fits, the blocking code looks security-sensitive, or the user might want to keep the route blocking on purpose — read [references/per-page-decisions.md](./references/per-page-decisions.md) before editing. Show the route while you ask: the `next-dev-loop` session runs the browser headed, so drive to the page and leave it on screen so the user is looking at the thing they're deciding about, with a screenshot as the fallback when a headed browser isn't possible. "Should this stay blocking?" is much easier to answer while looking at the page than at a file path. +- Don't narrate the refactor with comments. The only comment the codemod (or you) should leave is `// TODO: Cache Components adoption` on opt-outs, and the user's existing comments. Don't annotate every `` boundary or `"use cache"` call with what it does — the code says that. Drop a comment only when the _why_ isn't clear from the code (e.g. a deliberate Block with a reason). +- For many routes with the same mechanical fix, verify one representative route first. Then batch disjoint route groups using the same recipe, and run the shared build and browser checks together. + +Keep a todo list of the feature's routes. When every route in the feature is clean, move to step 3. + +## step 3: verify the feature + +Checklist before checking in with the user: + +- `next build` completes without blocking-route errors. +- No bare TODOs in the feature: `grep -rn "TODO: Cache Components adoption"` finds both the codemod's opt-out comments and the sync-IO unblocks from the pre-step. Any `instant = false` left behind is a deliberate, documented Block — comment rewritten to a reason (see [references/per-page-decisions.md](./references/per-page-decisions.md) → "when to leave a Block in place"). Any `await io()` or `await connection()` left behind has been reviewed and kept on purpose, not left over from the pre-step. +- Each route visited in the browser: confirm the static shell renders first and every `` fallback resolves to its real content. Capture both states if you can — the fallback (mid-stream) and the final paint — so you have a streaming-experience demo to show the user. Throttle the network in the browser if streaming is too fast to observe. +- After populating any new cache whose data can be updated, a mutation check confirms the next read returns the expected data. +- If runtime verification fails, reproduce the same route on the pre-adoption branch or with its opt-out restored. A failure that already exists is an environment or data problem, not an adoption regression. + +Then check in with the user. Same rule as the pre-step: speak their language. Don't say "feature-by-feature loop" or other internal labels; talk about the feature you adopted and what the user will see. + +- What you did: which routes you touched, and the user-visible result per route (e.g. "the post page now streams the article body behind a skeleton while the layout stays static"). +- What changed: opt-outs removed, fallbacks added, caching boundaries introduced. +- Show, don't tell. The `next-dev-loop` session runs the browser headed, so drive the route live for the user so they see the static shell → fallback → final content sequence in real time. If you can't drive a live browser, attach the before/after screenshots you captured instead. +- Give them the click-through: a short table of the feature's routes — the URL to open and what to look for (what renders instantly, which fallbacks appear, what streams in) — so they can verify each one themselves. +- The question: "Want to open this feature as a PR and move on to the next, or stop here?" Wait for the answer. + +**Trivial features can skip the check-in.** If adopting a feature only meant removing its `// TODO: Cache Components adoption` opt-out (no `` added, no `'use cache'` introduced, no render order change), the user sees nothing different. Move on to the next feature without stopping; mention it in passing the next time you do check in. + +When the loop has run on every feature — every remaining `instant = false` sits under a reason comment, `grep -rln "TODO: Cache Components adoption" app` returns nothing — point the user at [further reading](#further-reading) if they want to push the experience further, or stop and ship. + +### route table glyphs + +`ƒ` → `◐` is where adoption usually lands. `◐ (Partial Prerender)` means a static shell prerenders and the request-time content streams in — the goal state for any route that reads `cookies()`, `headers()`, `params`, or `searchParams`. Some routes legitimately stay `ƒ` when they do request-time work through a documented escape hatch (e.g. a layout that uses `await connection()`); the page is no longer _opted out_, it's genuinely dynamic. Don't remove the escape hatch only to chase a `◐`. The inverse holds: `instant = false` does not force a route to be `ƒ`. The glyph reflects what the route does at prerender time, not which validation knobs it exports. + +`◐` tells you a shell exists, not what's in it. A `` boundary placed too high (e.g. wrapping the entire page body, or `` around the article content) pushes the visible content out of the static shell into the streamed payload; the build still reports `◐` because _some_ shell prerendered (often only `` with framework markup). The route table can't tell you what's in the shell; a browser can. If the shell is empty and everything streams, pull the `` boundary down closer to the actual dynamic read. + +## further reading + +The work below is optional and lives in the docs — link the user to them and let them decide which to take on next. Don't walk these through inside this skill. + +- [Sweep for more instant navigations](./references/dev-only-validations.md) — an optional follow-up once adoption is done, never required. A passing build is not the last word, because dev validates every route on each page load (simulating both page loads and client navigations) and catches what the build's first-error exit and descendant shadowing skipped. Offer it as the smaller path to instant navigation for a user who doesn't want to adopt Partial Prefetching. Adopting Partial Prefetching (below) runs the same kind of loop and meets these insights anyway, so recommend both and let the user pick which, or whether. The reference is the loop to execute. +- [`next-partial-prefetching-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-partial-prefetching-adoption) — the follow-up skill that adopts Partial Prefetching: it enables `partialPrefetching` and audits every `` against a decision table (or adopts incrementally with the flag off, driven by the `instant-link-prefetch-partial` insight). It sequences this the same way this skill sequences Cache Components, but the insights are dev-only, so it's a browser click-through, not a build loop. Recommended after instant navigation, since those fixes feed directly into how much of each route the shell can prefetch. Concepts live in the [Adopting Partial Prefetching guide](https://nextjs.org/docs/app/guides/adopting-partial-prefetching). +- [Prevent regressions with e2e tests](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) — the `@next/playwright` [`instant()`](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/instant#testing-instant-navigation) helper asserts on the UI that's available immediately on navigation, so regressions surface in CI. Recommend it once a route is instant: `next-dev-loop` confirms it _now_; an `instant()` test keeps it that way. +- [`next-cache-components-optimizer`](https://github.com/vercel/next.js/tree/canary/skills/next-cache-components-optimizer) — a separate skill that grows each route's static shell so more of the page prerenders and less streams in. Pure optimization, not part of adoption. diff --git a/.agents/skills/next-cache-components-adoption/references/dev-only-validations.md b/.agents/skills/next-cache-components-adoption/references/dev-only-validations.md new file mode 100644 index 0000000..7c83fab --- /dev/null +++ b/.agents/skills/next-cache-components-adoption/references/dev-only-validations.md @@ -0,0 +1,29 @@ +# dev-only validation sweep + +How to surface and fix the instant-navigation insights that a clean `next build` does not show. The [Instant navigation guide](https://nextjs.org/docs/app/guides/instant-navigation) is the canonical reference for the validation model this exercises. + +## what the build misses + +By default (`validationLevel: 'warning'`) Cache Components validates every Page and Default segment in `next dev`, and the insights land in the dev overlay's Insights tab, not the build. Validation runs on every page load using the real request, and for each route it independently checks the initial page load and client navigations at different points in the hierarchy. So a `` boundary that covers the page load can still leave a client navigation blocking, and a layout stays clean at build time while a descendant keeps its `instant = false`. The build stops at the first blocking route and does not raise this family by default. Loading each route in dev is what surfaces it. + +## when to run it + +After the Cache Components build is clean, not before. While the app is mid-adoption the build redboxes mask this, so a full clean build (every route `◐`, no errors) is the precondition. A quiet sweep is the expected result of a clean adoption, not a missing signal. + +## the loop + +Reuse the [`next-dev-loop`](https://github.com/vercel/next.js/tree/canary/skills/next-dev-loop) preflight (Turbopack), then add one job. On a webpack app, drive a browser directly with `agent-browser` or Playwright instead. You lose the `/_next/mcp` cross-checks, not the insights, which still show in the overlay and the dev log. + +1. Build a route queue from the last build's route table or the app directory. +2. Load each route in `next dev` with a browser. A refresh or a link click both work, and validation simulates both the page-load and client-navigation cases on that load, so you do not need to click through every link by hand. Dynamic params are checked against the real values you visit, so hit a concrete `[slug]`, not the pattern. +3. Watch the dev log and the Insights tab. The dev log is the greppable record, one `Error: Route "...": Next.js encountered ...` line per insight with its `docs/messages/` link, and it reads the same on Turbopack and webpack. The Insights tab is amber and appears only once an insight fires, so a route with no tab is clean. Through `next-dev-loop`'s `/_next/mcp`, these come from `get_errors` and the overlay, not `get_request_insights`, which is the performance recorder and reports nothing here. +4. Open the linked page for each distinct insight and apply its fix (usually pull a `` boundary down to the read). Reload to confirm it clears. + +## gotchas + +- The overlay renders inside a shadow root (`nextjs-portal`), so accessibility-tree snapshots miss it. Read it through `shadowRoot`. +- No browser, no sweep. There is no build-only fallback for this family. Apply the static fix you can from the docs page (gate on type-check) and hand off the live confirmation. + +## when this shrinks + +Build-time instant validation is opt-in today (`experimental.instantInsights.validationLevel: 'experimental-error'`); the default `'warning'` surfaces in the overlay only. Once the build raises these reliably, the sweep collapses into reading `next build` output and this reference can shrink to that. diff --git a/.agents/skills/next-cache-components-adoption/references/per-page-decisions.md b/.agents/skills/next-cache-components-adoption/references/per-page-decisions.md new file mode 100644 index 0000000..4e57a73 --- /dev/null +++ b/.agents/skills/next-cache-components-adoption/references/per-page-decisions.md @@ -0,0 +1,27 @@ +# per-page decisions: removing `instant = false` + +Read this when a route still blocks after you remove its `instant = false` and the dev overlay's fix card isn't enough on its own. Each section here covers a judgment call the agent shouldn't make alone. + +## deciding what to do with a blocking read + +Read the full linked page behind the fix card — not only the inline snippet — before editing. The card unblocks the build, but the page covers the details that make the route's navigation actually instant (e.g. where to place a `` boundary). Don't improvise. + +If you're unsure which fix fits, the right call usually depends on what this part of the page is _for_, which the code doesn't capture. Ask the user about their goal for it rather than guessing. Frame it as a product/UX question: should this content be there instantly on load, or is it fine for it to stream in a moment later? Should everyone see the same thing (cacheable) or is it per-user / per-request? Tie the technical fix to that answer (cache it, wrap it in ``, or keep it request-time), so they're deciding the experience, not the API. + +## security gates and other code you can't infer + +If the blocking code looks like it's there for a _reason you can't infer_ — a security gate at the page top (`await verifyAccess()`, an auth redirect, a feature-flag check) where moving it inside `` would change what the code guarantees — stop and ask the user before refactoring. The build error wants ``, but wrapping a gate in `` defeats the gate. Only the person who wrote it knows whether to keep the route blocking (`instant = false` as a documented Block), restructure the page so the gate runs differently, move the check to [Proxy](https://nextjs.org/docs/app/api-reference/file-conventions/proxy), or — if the gate duplicates protection the app already relies on elsewhere (platform auth such as Vercel Deployment Protection, a Proxy check, a Data Access Layer) — remove it. + +Relocating a read (`await connection()`, Proxy) only changes _where_ it runs, never _whether it should run at all_ — so a gate that's redundant or broken looks identical to a correctly-placed one through the fix card's lens. If a gate looks strange, redundant, or out of place, say so plainly — "this looks like it might be unnecessary here — are you sure it belongs?" — instead of quietly relocating it. Surfacing the doubt is the agent's job; deciding is the user's. + +If _every_ route under a layout is gated this way, a documented Block on the layout is the correct end state. Moving the gate to [Proxy](https://nextjs.org/docs/app/api-reference/file-conventions/proxy) is the architectural fix, not a Cache Components one, and that's a follow-up rather than something to hold the migration on. + +For the broader picture, read the [Authentication guide](https://nextjs.org/docs/app/guides/authentication) (where auth checks belong: Proxy for routing, Data Access Layer for data) and the [Data Access Layer section of Data Security](https://nextjs.org/docs/app/guides/data-security#data-access-layer) (centralized auth checks that compose with `'use cache'`). + +If you don't know how to make a piece of code Cache Components–correct without changing what it does, ask. + +## when to leave a Block in place + +If a route is genuinely meant to block — it's inherently per-request with no useful static shell — or the refactor would be large and the user would rather not take it on now, that's a legitimate outcome. Keep `instant = false`, but confirm it with the user first and turn its `// TODO: Cache Components adoption` comment into a reason, e.g. `// instant = false: kept on purpose — fully request-time dashboard` or `// instant = false: deferred, refactor too large for now`. + +A documented, deliberate Block is fine to leave after the migration; an undocumented leftover opt-out is not. diff --git a/.agents/skills/next-cache-components-optimizer/SKILL.md b/.agents/skills/next-cache-components-optimizer/SKILL.md new file mode 100644 index 0000000..a487db5 --- /dev/null +++ b/.agents/skills/next-cache-components-optimizer/SKILL.md @@ -0,0 +1,482 @@ +--- +name: next-cache-components-optimizer +description: > + Drive a Next.js route to instant navigation by setting up an agentic loop, + under Cache Components / PPR, on initial load (hard navigation) and + client-side navigation (soft navigation). Encode the goal as a failing + @next/playwright instant() e2e and work it to green, one verified route at a + time; the shipped test then guards against regression. Use when asked to make + a route's navigation instant (its static shell commits immediately), fix a + route whose static shell isn't prerendered/served/prefetched, grow a route's + static shell or fix its slow first paint, diagnose which Suspense boundary + keeps a route out of its static shell, or write the instant() e2e guard for + one. Requires Next.js 16.3+ with cacheComponents; directs an upgrade if older. +--- + +# next-cache-components-optimizer + +Set up an agentic optimization loop that drives a Next.js route from "not +instant" to "instant" and keeps it there. The loop is test-driven: encode the +goal as a failing `@next/playwright` `instant()` test, work it to green, and +ship the test as the regression guard. Run it once per target route. Work the +phases P → G in order; each ends in a gate. Fix recipes live in two lazily-read +references — `reference/patterns.md` (before→after for each blocker type) and +`reference/real-app-patterns.md` (parallel routes, auth gates, the empty-shell +and responsive-skeleton failure modes). Read one only when its phase points +there. + +## What is invariant, and what is yours + +One thing here is fixed. The rest is yours. Read this before treating any +command, platform, or env var below as a requirement. + +- **Invariant: the verification loop.** Maximizing the shell is worthless + unless you can prove it. The proof is an automated check: under a lock that + gates dynamic data, the static shell still commits. RED shows the gap, GREEN + shows it closed, the test ships as the regression guard. It must run on a + production-like build and must not be able to pass vacuously. Stand the loop + up once; every later optimization is then verifiable by construction. The + loop is the deliverable, not any one route. +- **The mechanism: `@next/playwright` `instant()`.** This skill uses + [`instant()`](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) + as a ruler, not a stopwatch (phase A). It comes from + `@next/playwright` (installed alongside `@playwright/test`, on the same + release line as `next`), so it isn't tied to any host. Keep it. Timing a + navigation by hand is too flaky to trust, and is the failure mode this skill + exists to prevent. +- **Yours: the rig.** How you build, deploy, authenticate, configure + Playwright, and loop belongs to your stack, not to this skill. A local + `next build && next start`, a CI/staging container, and a per-push preview + deploy are equally valid rigs; the verdict comes from the build, never the + platform. Phase 0 maps the invariant onto your repo. Read every platform + name, env-var spelling, and command below as an example to translate, not a + requirement. + +## Two navigations, two loading states + +A route reaches the user two ways, and both must be instant: + +- **Initial load (hard navigation)** commits the route's prerendered static + shell; deferred parts stream in behind their loading skeletons (Suspense + fallbacks, `loading.tsx`). +- **Client-side navigation (soft navigation)** commits the destination's + prefetched App Shell — the `` default under Partial Prefetching — + re-rendering only the segments that change. + +The fix patterns are identical for both; the test differs only in how the +navigation is driven ("Driving the navigation in tests" below). The two shells +can differ; guard the one you ship, both when both matter +(`reference/real-app-patterns.md`). + +## Goal + +Maximizing the static shell is the optimization objective: the most meaningful +prerendered content commits immediately, and only genuinely per-request data +streams in afterward. The shipped test deterministically encodes **present ∧ +instant**; **non-blank** is the additional bar the workflow enforces by +judgment (D1/D2/E), because an `instant()` pass alone is satisfied by a blank +`fallback={null}` shell (the empty-shell failure mode, +`reference/real-app-patterns.md`). + +`instant()` is a ruler, not a stopwatch: assert that the shell appears under +the lock; do not time it. A trustworthy verdict requires a production build +(phase A). + +The GREEN under the lock is the deterministic verdict; each gate keeps it +trustworthy. + +## Reporting to the user + +This loop is meant to run unattended, so it doesn't stop to ask between steps. +Work the navigation the user named, finish it, and stop. What matters is how you +word and present the results, not how often you interrupt. The mechanics below — +the rig, RED, GREEN, the gates — are your scaffolding; the user never needs to +hear those words. + +- **Speak their language.** Describe the gap and the result in terms of what the + user sees: "navigating to the dashboard waited on the charts query before + anything painted; now the layout and skeletons paint instantly and the charts + stream in" — not RED/GREEN, the lock, or the phase letters. +- **Show, don't tell.** When you report a route, drive the browser (or attach + before/after screenshots) so the user watches the shell commit immediately and + the data stream in, rather than reading a claim. Identical before and after + means the fix did nothing — roll it back. +- **Present a run as a list of results the user can click through** — one line + per navigation: the route, what commits instantly, and what streams in — not a + transcript of the loop. +- **Only surface a question for a genuine fork:** a fix that would change + behavior, a security-sensitive read, or a route that's dynamic by design (a + per-link-prefetch candidate, not a shell to grow). A clean instant fix is not + a fork — keep going. With no one to ask (an unattended run), don't block: take + the safe default and note the assumption — for a cache-freshness choice, + defer the read behind `` (always fresh, still instant) rather than + guess a `cacheLife`. + +## The workflow + +``` +- [ ] P PREREQS Next.js 16.3+ with cacheComponents: true; upgrade first → below +- [ ] 0 SETUP once per repo: discover + write instant-nav.rig.md → rig-template.md +- [ ] A RIG production build with the testing API exposed → below +- [ ] B BASELINE unlocked: the marker renders for the test user → test-template.md +- [ ] C RED locked instant(): the shell does not commit → test-template.md +- [ ] C-gate VERIFY-RED: stop until the RED is trustworthy → reference/red-test-robustness.md +- [ ] D FIX push each Suspense boundary down to the data it guards → reference/patterns.md +- [ ] D1 reuse the route's existing loading UI; do not hand-build skeletons +- [ ] D2 the shell matches the real render at every breakpoint → reference/real-app-patterns.md +- [ ] E PARITY the refactor changed only whether the route is instant +- [ ] F DIFFERENTIAL revert only the fix → RED; re-apply → GREEN → reference/red-test-robustness.md +- [ ] G REVIEW PR checklist (below) +``` + +Phases B and C build the test; only the locked test from C ships. + +--- + +## P. PREREQUISITES: current Next.js with Cache Components + +The workflow depends on framework capabilities that ship with current Next.js: + +- **Next.js 16.3+ with `cacheComponents: true`** in `next.config.ts`. Without + Cache Components there is no static shell to optimize. +- **`@next/playwright`** on the same release line as the project's `next`; it + provides `instant()`. Verify with `npm ls next @next/playwright` (or the + project's package manager) and align them if they differ. The matching + testing API is in the `next` runtime, gated by the + `experimental.exposeTestingApiInProductionBuild` config flag (phase A). + +If the project does not meet these, upgrade first (`npx @next/codemod upgrade` +automates most of it), then enable Cache Components in `next.config.ts`: + +```ts +export default { cacheComponents: true } +``` + +Enabling the flag surfaces the blocking routes to resolve first; the +[`next-cache-components-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-cache-components-adoption) +skill drives that adoption. Reach for this optimizer once the app builds under +Cache Components. + +This gate is deliberate: the skill targets current Next.js, and none of the +verdicts below are meaningful on older versions. + +## 0. SETUP: discover this project's rig, once per repo + +The principles in this skill are fixed; the infrastructure they run on is +yours. On first use in a repository, discover how the project builds, deploys, +authenticates, and tests (inspect the repository first, and ask the user only +what it cannot answer), then write the answers to a committed +`instant-nav.rig.md`. Every later run reads that file instead of +rediscovering. The required build, test context, navigation contracts, +iteration loop, and file template are in **`rig-template.md`**. + +If the repo has no Playwright e2e harness yet, standing up a minimal one +(`@next/playwright`, a config with `baseURL`, one authenticated path) is part +of this step; the loop does not assume a pre-existing suite. + +## A. RIG: a production build with the testing API exposed + +Stand up the rig described by `instant-nav.rig.md`. Two invariants hold on +every platform: + +1. **Never measure on `next dev`.** It does not prefetch, and its lock is + unreliable for blocking routes, so a dev `instant()` result is not a valid + RED or GREEN. +2. **The measured build must expose the testing API.** Otherwise `instant()` + silently no-ops and the test passes vacuously (see + `reference/red-test-robustness.md`). The lock-engagement proof is the phase-C + RED itself: the unfixed target route is the known-blocking route, and its + RED under the lock shows the lock engages on this build (C-gate); the + self-validating variant in `test-template.md` is the in-band guarantee. Wire + `experimental.exposeTestingApiInProductionBuild` to a condition that is + true for every build you measure and never true in production: + + ```ts + experimental: { + // Use the condition your platform provides, and record it in the rig file: + // local: an explicit opt-in, as below + // generic CI: process.env.DEPLOY_ENV === 'staging' + // Vercel: process.env.VERCEL_ENV === 'preview' + exposeTestingApiInProductionBuild: + process.env.EXPOSE_TESTING_API === '1', + } + ``` + +The rig is any production-like build that exposes the testing API: a local +`next build && next start`, a CI/staging container, and a preview deploy are +all equally valid; the verdict comes from the build, not the platform. See +`rig-template.md` for the setup requirements. + +For any deployed or remote build, poll the rig's LIVENESS probe to confirm the +artifact contains `HEAD` before trusting a verdict (a stale deploy reads as a +false RED or GREEN); a local `next build && next start` needs none. The probe +mechanism is in `rig-template.md`. + +## B. BASELINE (unlocked): development scaffold, do not ship + +Drive the real navigation with no `instant()` lock and assert that the +destination's `SHELL_MARKER` renders **as the test user**: the account the +e2e suite authenticates as (in CI, the CI account; locally, your e2e login +fixture), with its flags, plan, role, and data. This establishes that the +marker is real and reachable: not flag-gated, not redirected away, not a +guessed selector. The suite runs as the test account, not the author's session; +that environment drift (the rig DRIFT list) is a common source of +untrustworthy REDs. Scaffold and run command: **`test-template.md`**. +**Delete this baseline before the PR.** + +## C. RED (locked) + the VERIFY-RED gate + +Wrap the same navigation in `instant()`; assert the shell commits under the +lock. A RED here is the gap. **This is the test that ships** +(`test-template.md`). + +Prefer the self-validating variant when the route has deferred content. If the +route cannot build while blocked, or a cookie/session read stays GREEN, use the +RED recipes in `reference/red-test-robustness.md`. + +> **C-gate: do not start optimizing until the RED is verified trustworthy.** A +> RED that is red for the wrong reason sends you optimizing a route that was +> never broken. + +The question that settles it: **does `SHELL_MARKER` render without the lock, +as the test user?** Answer it by re-running phase B as the test user, not by +adding assertions to the shipped test. The two-branch resolution (No → marker +or environment bug; Yes → genuine gap, proceed to D), the full taxonomy of +untrustworthy REDs, the checklist, and worked cases are in +**`reference/red-test-robustness.md`**. Read it now. + +--- + +## D. FIX: push each boundary down to the data it guards + +**The anti-pattern: one coarse boundary.** A single `` high in the +tree with a page-level fallback has three costs: + +- The layout UI stays out of the static shell: only a throwaway copy of it is + prerendered. +- The entire subtree is replaced when the boundary resolves, which discards + client state and shifts layout. +- The hand-built fallback drifts out of sync as the UI changes, because it + duplicates structure that also exists in the resolved tree. + +**The fix: hoist the static, push the Suspense down.** Render the layout UI +once, synchronously, in the shell, and wrap each await in a boundary scoped to +the single read it guards. Only that leaf streams; the stable ancestors are +reused as-is. + +**Rule:** if an element renders in both the fallback and the resolved tree, +hoist it above the boundary. + +### The most common blocker: a top-level `await` in a layout on a fallback route + +``` +app/[locale]/(app)/[tenant]/dashboard/... + │ generateStaticParams ✅ │ no generateStaticParams → fallback route +``` + +When any dynamic segment in the route lacks `generateStaticParams`, the route +is a fallback route, and **all** params defer to request time, including the +enumerated ones. A top-level `await` in a layout (`await params`, a +request-time session read, an auth gate) then blocks the whole subtree out of +the static shell, even when it reads a statically known param. Minimal shape: a +dynamic-segment route with one segment lacking `generateStaticParams`, plus a +top-level `await` in the layout above it. + +### The fix: defer the gate, render children + +Render `children` unconditionally; move the top-level `await` into a +``-wrapped child. Mechanism and before→after: +`reference/real-app-patterns.md`, "Deferring an auth gate". + +**Fix the page below the shell too, not only the layout.** A page-level +top-level `await` (commonly `await params`) blocks the same way the layout's +does, so make the page sync and push its dynamic reads into a +``-wrapped leaf as well. `fallback={null}` is correct only when a gate renders nothing on +success; for data, the fallback must be a real loading skeleton (see D1). + +Every other blocker shape — `cookies()`/`headers()`, uncached fetch or database +reads, `searchParams`, metadata, viewport, non-deterministic values (`Date.now()`, +`Math.random()`, `crypto.randomUUID()`) — surfaces its own insight when you hit +it: the build prints a `https://nextjs.org/docs/messages/` link. The +default build output is often abbreviated and may carry no usable stack trace; +add `--debug-prerender` for the full failing frame and to report every blocker +past the first. Scope the build to the route you're on with +`next build --debug-build-paths "app//**"` rather than rebuilding the app. +Open that page and apply its recipe; don't improvise from the inline message. + +The before→after recipe for each shape is in `reference/patterns.md`, which maps it to the insight +that explains it. + +A few things those per-error pages don't stress for the instant-navigation goal: + +- **A boundary in the root layout isn't enough for client navigations.** It + passes a page-load check but leaves sibling client navigations blocking; put + the boundary below the lowest layout the source and destination routes share. +- **Keep the LCP element** (usually the main heading) out of any boundary, so it + paints in the shell instead of waiting on a stream. +- **A green check isn't always instant.** `export const instant = false` opts + the segment out of validation while the navigation still blocks, and a + `` above the document `` prerenders an empty shell — neither + makes the route instant. + +### D1: reuse the route's existing loading UI; do not hand-build skeletons + +Before writing any skeleton, search the repository for the loading UI that +already exists for this route, in order: + +1. the route's `loading.tsx`; +2. an exported `*Skeleton` colocated with the component; +3. the fallback already inside the component's own ``. + +The **divergence point** is the lowest layout shared by the source and +destination routes: a soft navigation re-renders only the segments below it, +while an initial load re-runs every layout from the root. (Also called the +shared boundary.) A `loading.tsx` above the divergence point fills only +the initial-load shell; it sits above the soft-nav re-render scope. A +`loading.tsx` at the destination segment is itself the in-tree boundary for a +soft navigation into that segment and serves both. Reuse whichever boundary +actually covers the navigation you are shipping; below the divergence point, +`loading.tsx` and colocated skeletons are interchangeable for that purpose. + +If a component has no skeleton, extract its loading markup into a colocated +skeleton beside it. Do not author a fresh skeleton that mirrors the page +layout: it duplicates structure, drifts as the page changes, and pulls the +design back toward a single coarse boundary. Reusing the component's own +skeleton also keeps the prefetched shell consistent with the loaded UI. + +See: [Streaming](https://nextjs.org/docs/app/guides/streaming#push-dynamic-access-down) +and [loading states](https://nextjs.org/docs/app/guides/instant-navigation#iterate-on-loading-states). + +Exception: if the deferred component renders `null` for some users (for +example, a flag-gated control), `fallback={null}` is correct, since a skeleton +would flash and then collapse. + +### D2: the shell must match the real render at every breakpoint + +A skeleton frozen to one breakpoint misaligns on the others. Fix it the same +way: one responsive component renders both the live UI and the shell (D1 +skeleton in its data slots), so the breakpoint switch happens once. Verify by +re-asserting the shell marker at two widths +(`await page.setViewportSize({ width: 1280, height: 800 })`, then +`{ width: 390, height: 844 }`), or by adding a mobile Playwright project, so +this gate is as machine-checkable as the others. Detail: +`reference/real-app-patterns.md`. + +> **D-gate: phase D is complete when the locked test from phase C passes GREEN +> under the lock on the production-build rig**, not when the code compiles. That +> GREEN is the deterministic stop for the fix loop; proceed to E. + +If the optimization adds or expands a cache boundary, follow +[Revalidating](https://nextjs.org/docs/app/getting-started/revalidating). +A passing `instant()` test proves shell readiness, not mutation freshness. + +**When URL data can't be pushed down** (for example, the whole page depends on +`params`, `searchParams`, or the full URL), there may be no meaningful static +shell to grow. Don't force one. Per-link prefetching can make the soft +navigation instant, but it is outside this optimizer loop: it requires Partial +Prefetching, a ``, and cached URL-dependent content. See +[Optimizing prefetching](https://nextjs.org/docs/app/guides/optimizing-prefetching) +and pattern 10 in `reference/patterns.md` for the requirements, cost trade-offs, +manual prefetch caveat, and `instant()` test gotchas. + +## E. PARITY: the refactor changed only whether the route is instant + +The push-down is a mechanical transform, not a redesign. Afterward the route +must render the same tree, data, ordering, empty and error states, redirects, +and interactions as before; the only observable difference is that the shell +now commits instantly. Verify: + +- **Same render output.** The moved `await`s compute and return the same + values; after the stream, the route shows the same content as the base + branch for the test user. +- **Side effects still fire.** A deferred `redirect()` or `notFound()` still + happens, at request time rather than during prerender. Confirm an + unauthorized user is still redirected and a missing record still returns 404. +- **Both viewports reach the real UI** after the stream (D2). +- **Client state survives.** Because the layout UI is hoisted into the stable + shell rather than swapped on resolve, open menus, scroll position, focus, + and input state persist across the stream. +- **Pre-existing failures stay separate.** If the route errors after the + change, reproduce it on the base branch. The same failure there is an + environment or data problem, not an optimizer regression. + +If anything other than whether the route is instant changed, reduce the refactor. + +## F. DIFFERENTIAL + +Revert only the fix → RED; re-apply → GREEN; link both runs +(`reference/red-test-robustness.md`). On a deployed rig, confirm each run is live +(LIVENESS, phase A) before trusting its color. + +## G. REVIEW (PR checklist) + +A green final state means nothing if the RED was never trustworthy. The +test-trustworthiness items are the robustness checklist +(`reference/red-test-robustness.md`); confirm them, then require these +PR-specific items: + +- [ ] **Differential shown**: RED without the fix, GREEN with it, runs linked. +- [ ] **Parity confirmed (E)**: same content, redirects, and state. +- [ ] **Mutations verified when applicable**: after populating any cache whose + data can be updated, a mutation test confirms the next read returns the + expected data. +- [ ] **Existing loading UI reused (D1)**: no new page-mirroring skeleton. +- [ ] **Shell matches the real render at desktop and mobile widths (D2)**. +- [ ] **Baseline removed**: only the locked test from C remains. + +**Stop condition for the whole workflow:** the locked test from C is GREEN on +the rig, the differential (F) holds, and every item above is checked. Until all +three hold, you are not done. + +## Driving the navigation in tests + +- **Soft navigation** → drive a real `` click. **Initial load** → use + `page.goto()` inside `instant()` with the `baseURL` option. Do not substitute + `goto` for a soft-nav verdict; the two shells can differ + (`test-template.md`, `reference/real-app-patterns.md`). +- With parallel routes, only the slots that change re-render on a soft + navigation; client-rendered navigation UI does not re-render at all. Do not + chase a slot the navigation never touches + (`reference/real-app-patterns.md`). + +## Files + +- `rig-template.md`: phase 0 production build, test context, navigation + contract, and unattended loop discovery. +- `test-template.md`: the shipped `instant()` specs for both navigation + types (phase C), and the delete-before-PR baseline scaffold (phase B). +- `reference/red-test-robustness.md`: the C-gate and phase F. The taxonomy of + untrustworthy REDs, the checklist, the differential recipe, the vacuous-pass + failure mode, and worked cases. +- `reference/real-app-patterns.md`: parallel routes, deferring an auth gate, + initial-load vs soft-navigation shells, the empty-shell failure mode, the + responsive-skeleton mismatch, edge cases. + +## After optimization + +Once the target routes are instant, check whether the app has already adopted +Partial Prefetching (`partialPrefetching: true`, or the relevant destination +still uses `prefetch = 'partial'` during an incremental rollout). + +Make that check mechanically: + +```bash +rg -n "partialPrefetching|prefetch\s*=\s*['\"]partial['\"]" --glob 'next.config.*' --glob 'app/**' --glob 'src/app/**' +``` + +If `partialPrefetching: true` is in config, the app is globally adopted. If only +`prefetch = 'partial'` matches, treat those destination segments as adopted +during an incremental rollout and keep checking any other target routes. + +- **Already adopted:** for any URL-data route that stopped at the limitation + above, consider a targeted `` on the links where having + that URL-specific content ready before the click is worth the per-link server + work. Keep the default link behavior everywhere else so the shared App Shell + remains the low-cost baseline. +- **Not adopted yet:** recommend + [`next-partial-prefetching-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-partial-prefetching-adoption). + That skill moves the app onto the better prefetching model: shared App Shell + prefetches by default, fewer duplicated full-prefetch requests for visible + links, a link audit for existing `` usage, and optional + per-link prefetching only where URL-specific content is worth the + extra server work. diff --git a/.agents/skills/next-cache-components-optimizer/reference/patterns.md b/.agents/skills/next-cache-components-optimizer/reference/patterns.md new file mode 100644 index 0000000..9ec0cc0 --- /dev/null +++ b/.agents/skills/next-cache-components-optimizer/reference/patterns.md @@ -0,0 +1,357 @@ +# Refactor patterns — push dynamic down into the shell + +Each pattern is **before → after**: keep as much as possible in the prerendered shell, and wrap only genuinely per-request work in a tight `` (or hoist it into `use cache`). Production shapes — parallel-route slots, deferring an auth gate, client slot-routers — are in `real-app-patterns.md`. + +--- + +## 1. Awaiting at the top → move the await into a Suspense child + +The most common blocking shape. Awaiting request-time data at the top of a page/layout makes **everything below it** dynamic. + +```tsx +// ❌ before — top-level await of a non-static param + uncached data +export default async function Page(props: PageProps<'/store/[slug]'>) { + const { slug } = await props.params + const product = await db.products.findBySlug(slug) + return ( +
+

{product.name}

+
+ ) +} +``` + +```tsx +// ✅ after — pass the params promise down; await inside a Suspense-wrapped child +import { Suspense } from 'react' + +export default function Page(props: PageProps<'/store/[slug]'>) { + return ( + Loading product…

}> + +
+ ) +} + +async function Product({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const product = await db.products.findBySlug(slug) + return ( +
+

{product.name}

+
+ ) +} +``` + +Inline variant when you don't want a separate component — unwrap the promise without awaiting at the top: + +```tsx +export default function Page(props: PageProps<'/store/[category]'>) { + return ( + }> + {props.params.then(({ category }) => ( + + ))} + + ) +} +``` + +**Insight:** [runtime data during prerendering](https://nextjs.org/docs/messages/blocking-prerender-runtime). + +--- + +## 2. `cookies()` / `headers()` in a layout → start, don't await; pass down + +A layout that awaits request data blocks the layout **and every page under it**. + +```tsx +// ❌ before — whole layout (and all children) becomes dynamic +export default async function Layout({ children }) { + const cookieStore = await cookies() + const theme = cookieStore.get('theme')?.value + return {children} +} +``` + +```tsx +// ✅ after — start the read without awaiting, pass the promise to a Suspense child +import { Suspense } from 'react' +import { cookies } from 'next/headers' + +export default function Layout({ children }: { children: React.ReactNode }) { + const cookieStore = cookies() // not awaited → does not block the shell + return ( + + + {children} + + ) +} + +async function UserMenu({ + cookiePromise, +}: { + cookiePromise: ReturnType +}) { + const theme = (await cookiePromise).get('theme')?.value + return
+} +``` + +`{children}` and `