Files
next.js/packages/next-codemod/transforms/cache-components-instant-false.ts
Aurora ScharffandGitHub ae62a6f79b codemod: cache-components-instant-false + next-cache-components-adoption skill (#94941)
### Why?

Turning on `cacheComponents` makes every blocking route a build error at
once. On a large app that's a wall of failures with no clear order to
fix them in, which is a rough first experience.

### What?

An opt-in adoption path so enabling Cache Components doesn't have to be
all-or-nothing:

- **`cache-components-instant-false` codemod** (`@next/codemod`) —
blanket-inserts `export const instant = false` (with a `// TODO: Cache
Components adoption` comment) into every `app/**/{page,layout}` file, so
the build goes green immediately. Idempotent: skips files that already
export `instant` (including aliased `export { x as instant }`). Skips
Client Components (`"use client"`) and route handlers.
- **`next-cache-components-adoption` skill** (`skills/`) — sequences the
full migration in five steps across four milestones: green build
(codemod or direct), remove the opt-outs top-down one group at a time,
make navigations instant via the dev-overlay Insights, then adopt
Partial Prefetching. It defers individual fixes to the dev overlay fix
cards, stack traces, and the `/docs/messages/blocking-prerender-*`
pages, and points at the migration / instant-navigation / Partial
Prefetching guides rather than re-teaching them. Verifies each change at
runtime via `next-dev-loop` (with a manual dev-overlay fallback). The
`next-cache-components-optimizer` skill is referenced only as optional
shell-growing polish after adoption is complete.

### How?

- Transform registered in `TRANSFORMER_INQUIRER_CHOICES` at `version:
'16.3.0'`.
- 8 fixtures cover insert-into-page / -layout, JSDoc placement, and
no-op on existing / aliased / multi-declarator / local-binding `instant`
and on `"use client"` pages. `pnpm jest cache-components-instant-false`
passes.
- Docs: a `cache-components-instant-false` entry in the codemods
reference, and a pointer to the skill + codemod from the migration
guide.
2026-06-22 20:52:16 +02:00

157 lines
5.7 KiB
TypeScript

import type { API, FileInfo } from 'jscodeshift'
import { createParserFromPath } from '../lib/parser'
/**
* Blanket-inserts `export const instant = false` into every App Router `page`,
* `layout`, and `default` file so they're marked as allowed to block when
* `cacheComponents` is enabled. Each opt-out is meant to be walked back, one
* route at a time, using the companion adoption skill.
*
* - Skips files that already declare or export `instant` in any form (never
* overrides existing config or appends a duplicate binding).
* - Skips Client/Server Component modules (`"use client"` / `"use server"`):
* `instant` is a Server Component route segment config, so exporting it from
* those modules is a build error.
* - Targets `page` / `layout` / `default` only (not `route` — `instant` does
* not apply to route handlers). `default.tsx` is the parallel-route fallback,
* a server segment that accepts route segment config like the other two.
*/
export default function transformer(file: FileInfo, _api: API) {
if (
process.env.NODE_ENV !== 'test' &&
!/(^|[/\\])app[/\\].*?(page|layout|default)\.[^/\\]+$/.test(file.path)
) {
return file.source
}
const j = createParserFromPath(file.path)
const root = j(file.source)
// Bail on Client/Server Component modules. `instant` is a Server Component
// route segment config; exporting it from a `"use client"` (or `"use server"`)
// module fails the build. Parsers represent the directive either in
// `program.directives` or as a leading string-literal `ExpressionStatement`.
const program = root.get().node.program
const isClientOrServerDirective = (value: unknown) =>
value === 'use client' || value === 'use server'
let hasModuleDirective = (program.directives ?? []).some((d: any) =>
isClientOrServerDirective(d?.value?.value)
)
if (!hasModuleDirective) {
for (const node of program.body) {
if (
node.type !== 'ExpressionStatement' ||
(node.expression?.type !== 'StringLiteral' &&
node.expression?.type !== 'Literal')
) {
// Directives must lead the module; stop at the first non-directive.
break
}
if (isClientOrServerDirective(node.expression.value)) {
hasModuleDirective = true
break
}
}
}
if (hasModuleDirective) {
return file.source
}
// Bail if `instant` already exists in any form, so we never append a
// duplicate declaration (which would be a `SyntaxError`). This covers:
// export const instant = ...
// export const a = 1, instant = ... (any declarator position)
// const instant = ... (local binding)
// const { instant } = ... (destructured binding)
// export { instant }
// export { foo as instant }
// export function instant() {} / export class instant {}
const bindsInstant = (node: any): boolean => {
switch (node?.type) {
case 'Identifier':
return node.name === 'instant'
case 'ObjectPattern':
return node.properties.some((prop: any) =>
prop.type === 'RestElement'
? bindsInstant(prop.argument)
: bindsInstant(prop.value ?? prop.argument)
)
case 'ArrayPattern':
return node.elements.some((el: any) => el != null && bindsInstant(el))
case 'AssignmentPattern':
return bindsInstant(node.left)
case 'RestElement':
return bindsInstant(node.argument)
default:
return false
}
}
const hasInstantBinding =
root
.find(j.VariableDeclarator)
.filter((p) => bindsInstant(p.node.id))
.size() > 0 ||
root.find(j.ExportSpecifier, { exported: { name: 'instant' } }).size() >
0 ||
root.find(j.FunctionDeclaration, { id: { name: 'instant' } }).size() > 0 ||
root.find(j.ClassDeclaration, { id: { name: 'instant' } }).size() > 0
if (hasInstantBinding) {
return file.source
}
// Build `export const instant = false`. The two `//` comments above it
// (TODO + See:) are attached as leading comments on the declaration so
// recast prints them right above it.
const todoComment = j.commentLine(
' TODO: Cache Components adoption. Refactor this route so this opt-out can be removed.',
true,
false
)
const seeComment = j.commentLine(
' See: https://nextjs.org/docs/app/guides/migrating-to-cache-components',
true,
false
)
const instantExport = j.exportNamedDeclaration(
j.variableDeclaration('const', [
j.variableDeclarator(j.identifier('instant'), j.booleanLiteral(false)),
])
)
instantExport.comments = [todoComment, seeComment]
// Insert after the last top-level import, or at the top of the module
// if there are no imports.
const body = program.body as any[]
let lastImportIndex = -1
for (let i = 0; i < body.length; i++) {
if (body[i].type === 'ImportDeclaration') lastImportIndex = i
}
if (lastImportIndex !== -1) {
body.splice(lastImportIndex + 1, 0, instantExport)
} else if (body.length > 0) {
// No imports. Inserting at index 0 would steal any file-level leading
// comments (e.g. `// @ts-nocheck`) from `body[0]` because recast
// attributes them to whatever is first. Move those leading comments
// off `body[0]` onto the new export *before* its TODO/See: lines, so
// they print in their original position.
const first = body[0]
const allComments = (first.comments ?? []) as any[]
const firstLeading = allComments.filter((c) => c.leading === true)
if (firstLeading.length > 0) {
first.comments = allComments.filter((c) => c.leading !== true)
instantExport.comments = [...firstLeading, todoComment, seeComment]
}
body.unshift(instantExport)
} else {
body.push(instantExport)
}
return root.toSource()
}