Files
vercel__next.js/packages/next-codemod/transforms/remove-partial-prefetch.ts
Aurora Scharff 8a4920c15a docs: clarify first-party Skills workflows (#96495)
## Summary

- clarify how runtime foundations, interactive workflows, and unattended
loops fit into the AI agents and Instant Navigation guides
- harden the Cache Components and Partial Prefetching Skills around
capability checks, trustworthy `instant()` tests, route scope, and
real-app verification failures
- add migration-guide comments to temporary Partial Prefetching route
config and update `remove-partial-prefetch` to remove only generated
comments while preserving runtime-prefetch TODOs and user comments

This keeps the experimental Partial Prefetching optimizer in #96471
separate.

## Testing

- `pnpm --filter @next/codemod build`
- `pnpm --filter @next/codemod test --
transforms/__tests__/remove-partial-prefetch.test.js --runInBand`
2026-08-03 19:23:56 +00:00

254 lines
8.5 KiB
TypeScript

import type { API, FileInfo, JSCodeshift } from 'jscodeshift'
import type { VariableDeclaration } from 'jscodeshift'
import { createParserFromPath } from '../lib/parser'
// Route Segment Config name and the only value this codemod strips.
const CONFIG_NAME = 'prefetch'
const TARGET_VALUE = 'partial'
const GENERATED_GUIDE_COMMENT =
'See: https://nextjs.org/docs/app/guides/adopting-partial-prefetching'
function isGeneratedGuideComment(comment: any): boolean {
return comment.value.trim() === GENERATED_GUIDE_COMMENT
}
function removeGeneratedGuideComment(node: any) {
for (const target of [node, node.declaration]) {
if (!target?.comments) continue
target.comments = target.comments.filter(
(comment: any) => !isGeneratedGuideComment(comment)
)
}
}
// Unwrap `'partial' as const` / `'partial' satisfies T` so the value guard
// still matches when the export is annotated.
function unwrap(j: JSCodeshift, node: any) {
if (
(j.TSAsExpression && j.TSAsExpression.check(node)) ||
(j.TSSatisfiesExpression && j.TSSatisfiesExpression.check(node))
) {
return node.expression
}
return node
}
// Only `prefetch = 'partial'` matches. A different value such as
// `prefetch = 'allow-runtime'` is a legitimate config and is left untouched.
function isTargetPrefetch(j: JSCodeshift, decl: any): boolean {
if (!j.VariableDeclarator.check(decl) || !j.Identifier.check(decl.id)) {
return false
}
if (decl.id.name !== CONFIG_NAME || !decl.init) {
return false
}
const init = unwrap(j, decl.init)
return (
(j.StringLiteral.check(init) && init.value === TARGET_VALUE) ||
(j.Literal.check(init) && init.value === TARGET_VALUE)
)
}
// Drop only the `prefetch = 'partial'` declarator, leaving any sibling
// declarators (e.g. `export const runtime = 'edge', prefetch = 'partial'`)
// intact. Returns the number of declarators left so the caller can remove the
// whole statement when it becomes empty.
function stripTargetDeclarators(
j: JSCodeshift,
declaration: VariableDeclaration
): number {
declaration.declarations = declaration.declarations.filter(
(decl) => !isTargetPrefetch(j, decl)
)
return declaration.declarations.length
}
// Removing a statement also removes the comments attached above it — which
// may be a user's note or a deliberate `// TODO(...)` marker. Reattach the
// leading comments to the next statement (or the previous one when the
// removed statement is last) so they survive the removal.
function preserveLeadingComments(path: any) {
const comments = path.node.comments?.filter(
(comment: any) => comment.leading && !isGeneratedGuideComment(comment)
)
if (!comments?.length) {
return
}
const body = path.parent.node.body
if (!Array.isArray(body)) {
return
}
const index = body.indexOf(path.node)
const next = body[index + 1]
const prev = body[index - 1]
if (next) {
next.comments = [...comments, ...(next.comments ?? [])]
} else if (prev) {
for (const comment of comments) {
comment.leading = false
comment.trailing = true
}
prev.comments = [...(prev.comments ?? []), ...comments]
}
}
export default function transformer(file: FileInfo, _api: API) {
// Run on App Router page/layout files, except for test environment. The
// `prefetch` Route Segment Config only applies to pages and layouts, so
// route handlers are intentionally excluded.
// `(^|[/\\])app` matches both an absolute path and a relative `app/...` path
// (what `npx @next/codemod ... ./app` passes), so top-level app files aren't
// silently skipped.
if (
process.env.NODE_ENV !== 'test' &&
!/(^|[/\\])app[/\\](?:.*[/\\])?(page|layout)(\.[^/\\]*)?$/.test(file.path)
) {
return file.source
}
const j = createParserFromPath(file.path)
const root = j(file.source)
let hasChanges = false
// `export const prefetch = 'partial'` (possibly alongside other configs in
// the same statement).
root
.find(j.ExportNamedDeclaration, {
declaration: { type: 'VariableDeclaration' },
})
.filter((path) => {
const declaration = path.node.declaration
return (
j.VariableDeclaration.check(declaration) &&
declaration.declarations.some((decl) => isTargetPrefetch(j, decl))
)
})
.forEach((path) => {
removeGeneratedGuideComment(path.node)
const declaration = path.node.declaration as VariableDeclaration
const remaining = stripTargetDeclarators(j, declaration)
// Remove the whole export only when nothing else was declared with it.
if (remaining === 0) {
preserveLeadingComments(path)
j(path).remove()
}
hasChanges = true
})
// Bare `const prefetch = 'partial'` declarations are only Route Segment
// Configs when the file also exports them as `prefetch` via a local
// `export { prefetch }`. Re-exports (`export { prefetch } from '...'`) bind
// another module's value, and aliased exports export a different name
// (`export { prefetch as other }`) or a different binding
// (`export { other as prefetch }`), so neither counts. When an aliased
// export shares the `prefetch` binding, removing the declaration would
// break it, so the whole file is left untouched.
let hasPlainPrefetchExportSpecifier = false
let hasAliasedPrefetchBinding = false
root
.find(j.ExportNamedDeclaration)
.filter((path) => !path.node.source)
.forEach((path) => {
for (const spec of path.node.specifiers ?? []) {
if (
!j.ExportSpecifier.check(spec) ||
!j.Identifier.check(spec.local) ||
spec.local.name !== CONFIG_NAME
) {
continue
}
if (
j.Identifier.check(spec.exported) &&
spec.exported.name === CONFIG_NAME
) {
hasPlainPrefetchExportSpecifier = true
} else {
hasAliasedPrefetchBinding = true
}
}
})
const hasLocalPrefetchExportSpecifier =
hasPlainPrefetchExportSpecifier && !hasAliasedPrefetchBinding
// Track that we removed a bare declaration so we only drop the matching
// export specifier below.
let removedBareDeclaration = false
if (hasLocalPrefetchExportSpecifier) {
root
.find(j.VariableDeclaration)
.filter((path) => {
// `export const prefetch` is handled above; skip it here.
if (j.ExportNamedDeclaration.check(path.parent.node)) {
return false
}
// Only top-level declarations can be the exported config. A local
// `const prefetch` inside a function or block is unrelated code.
if (!j.Program.check(path.parent.node)) {
return false
}
return path.node.declarations.some((decl) => isTargetPrefetch(j, decl))
})
.forEach((path) => {
removeGeneratedGuideComment(path.node)
const remaining = stripTargetDeclarators(j, path.node)
if (remaining === 0) {
preserveLeadingComments(path)
j(path).remove()
}
removedBareDeclaration = true
hasChanges = true
})
}
// Handle `export { prefetch }` and `export { prefetch, other }`, but only
// when the paired declaration was the `'partial'` one we removed above.
if (removedBareDeclaration) {
root
.find(j.ExportNamedDeclaration)
// Skip re-exports (`export { prefetch } from '...'`): their specifiers
// reference another module's binding, not the declaration we removed.
.filter(
(path) => !path.node.source && Boolean(path.node.specifiers?.length)
)
.forEach((path) => {
const specifiers = path.node.specifiers
if (!specifiers) return
const filteredSpecifiers = specifiers.filter((spec) => {
// Remove only the plain `export { prefetch }` specifier. Aliased
// specifiers export a different name or bind a different value, so
// they aren't the Route Segment Config.
if (
j.ExportSpecifier.check(spec) &&
j.Identifier.check(spec.local) &&
j.Identifier.check(spec.exported)
) {
return !(
spec.local.name === CONFIG_NAME &&
spec.exported.name === CONFIG_NAME
)
}
return true
})
if (filteredSpecifiers.length !== specifiers.length) {
hasChanges = true
if (filteredSpecifiers.length === 0) {
preserveLeadingComments(path)
j(path).remove()
} else {
path.node.specifiers = filteredSpecifiers
}
}
})
}
if (hasChanges) {
return root.toSource()
}
return file.source
}