mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
## What Follow-up to #95365. Documents adopting Partial Prefetching end to end: - Restructures the [Adopting Partial Prefetching guide](https://github.com/vercel/next.js/blob/canary/docs/01-app/02-guides/adopting-partial-prefetching.mdx) around the two paths: enable `partialPrefetching` and audit every `<Link prefetch={true}>` against a per-case decision table, or adopt incrementally per route with the [`instant-link-prefetch-partial`](https://nextjs.org/docs/messages/instant-link-prefetch-partial) insight as the worklist. Adds `Auditing routes for URL data` and `Prefetching URL data` sections. - Adds the `remove-partial-prefetch` codemod, which strips the redundant per-route `prefetch = 'partial'` exports once the flag is on while preserving comments and other `prefetch` values. - Adds the `next-partial-prefetching-adoption` skill that sequences the guide for agents: audit, enable + codemod, URL-data sweep, verify, optional runtime prefetching. - Aligns the `instant-link-prefetch-partial` error page, `<Link>` reference, glossary, and Interactive apps guide with the same prefetch model. ## Why With the flag on, `<Link prefetch={true}>` no longer delivers dynamic content, and shell validation flags `params`/`searchParams` reads outside `<Suspense>`. Adopters need one guide that sequences the audit, the flag, and both insights, and an agent skill that follows it. Stacked on #95365 (base: `codex/instant-shell-link-data`) since it links to the `instant-shell-url-data` error page that PR introduces. GitHub will retarget to `canary` when that merges. <!-- NEXT_JS_LLM_PR -->
235 lines
7.9 KiB
TypeScript
235 lines
7.9 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'
|
|
|
|
// 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)
|
|
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) => {
|
|
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) => {
|
|
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
|
|
}
|