mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
b0ba92cbc6
### Why? Async Request API codemods can leave temporary casts that still require application-specific repairs. Those locations need explicit markers, while optional feature adoption needs to be distinguishable from required version migrations. ### How? Add `@next-codemod-error` comments to temporary `UnsafeUnwrapped*` casts, with instructions for completing the repair. Preserve documented ignores and avoid duplicating casts or markers when the transform runs again. Mark generated Cache Components opt-outs with `@next-codemod-ignore`, a removal condition and a link to the adoption guide. Add `upgrade --skip-adoption` to skip the Cache Components `instant = false` transform and partial-prefetch adoption cleanup. Required migrations and normal dependency and bundler selection keep their existing behavior. Both adoption transforms can still be run explicitly.
435 lines
15 KiB
TypeScript
435 lines
15 KiB
TypeScript
import type { API, ASTPath, CallExpression, Collection } from 'jscodeshift'
|
|
import {
|
|
determineClientDirective,
|
|
isFunctionType,
|
|
isMatchedFunctionExported,
|
|
turnFunctionReturnTypeToAsync,
|
|
insertReactUseImport,
|
|
isFunctionScope,
|
|
findClosetParentFunctionScope,
|
|
wrapParentheseIfNeeded,
|
|
insertCommentOnce,
|
|
NEXTJS_ENTRY_FILES,
|
|
containsReactHooksCallExpressions,
|
|
isParentUseCallExpression,
|
|
isReactHookName,
|
|
} from './utils'
|
|
import { createParserFromPath } from '../../../lib/parser'
|
|
import { NEXT_CODEMOD_ERROR_PREFIX } from '../../../lib/utils'
|
|
|
|
const DYNAMIC_IMPORT_WARN_COMMENT = ` ${NEXT_CODEMOD_ERROR_PREFIX} The APIs under 'next/headers' are async now, need to be manually awaited. `
|
|
|
|
function findDynamicImportsAndComment(root: Collection<any>, j: API['j']) {
|
|
let modified = false
|
|
// find all the dynamic imports of `next/headers`,
|
|
// and add a comment to the import expression to inform this needs to be manually handled
|
|
|
|
// find all the dynamic imports of `next/cookies`,
|
|
// Notice, import() is not handled as ImportExpression in current jscodeshift version,
|
|
// we need to use CallExpression to capture the dynamic imports.
|
|
const importPaths = root.find(j.CallExpression, {
|
|
callee: {
|
|
type: 'Import',
|
|
},
|
|
arguments: [{ value: 'next/headers' }],
|
|
})
|
|
|
|
importPaths.forEach((path) => {
|
|
const inserted = insertCommentOnce(
|
|
path.node,
|
|
j,
|
|
DYNAMIC_IMPORT_WARN_COMMENT
|
|
)
|
|
modified ||= inserted
|
|
})
|
|
return modified
|
|
}
|
|
|
|
export function transformDynamicAPI(
|
|
source: string,
|
|
_api: API,
|
|
filePath: string
|
|
) {
|
|
const isEntryFile = NEXTJS_ENTRY_FILES.test(filePath)
|
|
const j = createParserFromPath(filePath)
|
|
const root = j(source)
|
|
let modified = false
|
|
|
|
// Check if 'use' from 'react' needs to be imported
|
|
let needsReactUseImport = false
|
|
const insertedTypes = new Set<string>()
|
|
|
|
function isImportedInModule(
|
|
path: ASTPath<CallExpression>,
|
|
functionName: string
|
|
) {
|
|
const closestDef = j(path)
|
|
.closestScope()
|
|
.findVariableDeclarators(functionName)
|
|
return closestDef.size() === 0
|
|
}
|
|
|
|
function processAsyncApiCalls(
|
|
asyncRequestApiName: string,
|
|
originRequestApiName: string
|
|
) {
|
|
// Process each call to cookies() or headers()
|
|
root
|
|
.find(j.CallExpression, {
|
|
callee: {
|
|
type: 'Identifier',
|
|
name: asyncRequestApiName,
|
|
},
|
|
})
|
|
.forEach((path) => {
|
|
const isImportedTopLevel = isImportedInModule(path, asyncRequestApiName)
|
|
if (!isImportedTopLevel) {
|
|
return
|
|
}
|
|
let parentFunctionPath = findClosetParentFunctionScope(path, j)
|
|
// We found the parent scope is not a function
|
|
let parentFunctionNode
|
|
if (parentFunctionPath) {
|
|
if (isFunctionScope(parentFunctionPath, j)) {
|
|
parentFunctionNode = parentFunctionPath.node
|
|
} else {
|
|
const scopeNode = parentFunctionPath.node
|
|
if (
|
|
scopeNode.type === 'ReturnStatement' &&
|
|
isFunctionType(scopeNode.argument.type)
|
|
) {
|
|
parentFunctionNode = scopeNode.argument
|
|
}
|
|
}
|
|
}
|
|
|
|
const isAsyncFunction = parentFunctionNode?.async || false
|
|
|
|
const isCallAwaited = path.parentPath?.node?.type === 'AwaitExpression'
|
|
|
|
const hasChainAccess =
|
|
path.parentPath.value.type === 'MemberExpression' &&
|
|
path.parentPath.value.object === path.node
|
|
|
|
const closetScope = j(path).closestScope()
|
|
// For cookies/headers API, only transform server and shared components
|
|
if (isAsyncFunction) {
|
|
if (!isCallAwaited) {
|
|
// Add 'await' in front of cookies() call
|
|
const expr = j.awaitExpression(
|
|
// add parentheses to wrap the function call
|
|
j.callExpression(j.identifier(asyncRequestApiName), [])
|
|
)
|
|
j(path).replaceWith(wrapParentheseIfNeeded(hasChainAccess, j, expr))
|
|
modified = true
|
|
}
|
|
} else {
|
|
// Determine if the function is an export
|
|
const closetScopePath = closetScope.get()
|
|
const isEntryFileExport =
|
|
isEntryFile && isMatchedFunctionExported(closetScopePath, j)
|
|
const closestFunctionNode = closetScope.size()
|
|
? closetScopePath.node
|
|
: null
|
|
|
|
// If it's exporting a function directly, exportFunctionNode is same as exportNode
|
|
// e.g. export default function MyComponent() {}
|
|
// If it's exporting a variable declaration, exportFunctionNode is the function declaration
|
|
// e.g. export const MyComponent = function() {}
|
|
let exportFunctionNode
|
|
|
|
if (isEntryFileExport) {
|
|
if (
|
|
closestFunctionNode &&
|
|
isFunctionType(closestFunctionNode.type)
|
|
) {
|
|
exportFunctionNode = closestFunctionNode
|
|
}
|
|
} else {
|
|
// Is normal async function
|
|
exportFunctionNode = closestFunctionNode
|
|
}
|
|
|
|
let canConvertToAsync = false
|
|
// check if current path is under the default export function
|
|
if (isEntryFileExport) {
|
|
// if default export function is not async, convert it to async, and await the api call
|
|
if (!isCallAwaited && isFunctionType(exportFunctionNode.type)) {
|
|
const hasReactHooksUsage = containsReactHooksCallExpressions(
|
|
closetScopePath,
|
|
j
|
|
)
|
|
// If the scoped function is async function
|
|
if (exportFunctionNode.async === false && !hasReactHooksUsage) {
|
|
canConvertToAsync = true
|
|
exportFunctionNode.async = true
|
|
}
|
|
|
|
if (canConvertToAsync) {
|
|
const expr = j.awaitExpression(
|
|
j.callExpression(j.identifier(asyncRequestApiName), [])
|
|
)
|
|
j(path).replaceWith(
|
|
wrapParentheseIfNeeded(hasChainAccess, j, expr)
|
|
)
|
|
|
|
turnFunctionReturnTypeToAsync(closetScopePath.node, j)
|
|
modified = true
|
|
} else {
|
|
// If it's still sync function that cannot be converted to async, wrap the api call with 'use()' if needed
|
|
if (!isParentUseCallExpression(path, j)) {
|
|
j(path).replaceWith(
|
|
j.callExpression(j.identifier('use'), [
|
|
j.callExpression(j.identifier(asyncRequestApiName), []),
|
|
])
|
|
)
|
|
needsReactUseImport = true
|
|
modified = true
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// if parent is function and it's a hook, which starts with 'use', wrap the api call with 'use()'
|
|
const parentFunction = findClosetParentFunctionScope(path, j)
|
|
|
|
if (parentFunction) {
|
|
const parentFunctionName =
|
|
parentFunction.get().node.id?.name || ''
|
|
const isParentFunctionHook = isReactHookName(parentFunctionName)
|
|
if (isParentFunctionHook && !isParentUseCallExpression(path, j)) {
|
|
j(path).replaceWith(
|
|
j.callExpression(j.identifier('use'), [
|
|
j.callExpression(j.identifier(asyncRequestApiName), []),
|
|
])
|
|
)
|
|
needsReactUseImport = true
|
|
} else {
|
|
const casted = castTypesOrAddComment(
|
|
j,
|
|
path,
|
|
originRequestApiName,
|
|
root,
|
|
filePath,
|
|
insertedTypes,
|
|
` ${NEXT_CODEMOD_ERROR_PREFIX} Manually await this call and refactor the function to be async `
|
|
)
|
|
modified ||= casted
|
|
}
|
|
} else {
|
|
const casted = castTypesOrAddComment(
|
|
j,
|
|
path,
|
|
originRequestApiName,
|
|
root,
|
|
filePath,
|
|
insertedTypes,
|
|
` ${NEXT_CODEMOD_ERROR_PREFIX} please manually await this call, codemod cannot transform due to undetermined async scope `
|
|
)
|
|
modified ||= casted
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
// Handle type usage of async API, e.g. `type Cookie = ReturnType<typeof cookies>`
|
|
// convert it to `type Cookie = Awaited<ReturnType<typeof cookies>>`
|
|
root
|
|
.find(j.TSTypeReference, {
|
|
typeName: {
|
|
type: 'Identifier',
|
|
name: 'ReturnType',
|
|
},
|
|
})
|
|
.forEach((path) => {
|
|
const typeParam = path.node.typeParameters?.params[0]
|
|
|
|
// Check if the ReturnType is for 'cookies'
|
|
if (
|
|
typeParam &&
|
|
j.TSTypeQuery.check(typeParam) &&
|
|
j.Identifier.check(typeParam.exprName) &&
|
|
typeParam.exprName.name === asyncRequestApiName
|
|
) {
|
|
// Replace ReturnType<typeof cookies> with Awaited<ReturnType<typeof cookies>>
|
|
const awaitedTypeReference = j.tsTypeReference(
|
|
j.identifier('Awaited'),
|
|
j.tsTypeParameterInstantiation([
|
|
j.tsTypeReference(
|
|
j.identifier('ReturnType'),
|
|
j.tsTypeParameterInstantiation([typeParam])
|
|
),
|
|
])
|
|
)
|
|
|
|
j(path).replaceWith(awaitedTypeReference)
|
|
|
|
modified = true
|
|
}
|
|
})
|
|
}
|
|
|
|
const isClientComponent = determineClientDirective(root, j)
|
|
|
|
// Only transform the valid calls in server or shared components
|
|
if (isClientComponent) return null
|
|
|
|
// Import declaration case, e.g. import { cookies } from 'next/headers'
|
|
const importedNextAsyncRequestApisMapping = findImportMappingFromNextHeaders(
|
|
root,
|
|
j
|
|
)
|
|
for (const originName in importedNextAsyncRequestApisMapping) {
|
|
const aliasName = importedNextAsyncRequestApisMapping[originName]
|
|
processAsyncApiCalls(aliasName, originName)
|
|
}
|
|
|
|
// Add import { use } from 'react' if needed and not already imported
|
|
if (needsReactUseImport) {
|
|
insertReactUseImport(root, j)
|
|
}
|
|
|
|
const commented = findDynamicImportsAndComment(root, j)
|
|
modified ||= commented
|
|
|
|
return modified ? root.toSource() : null
|
|
}
|
|
|
|
// cast to unknown first, then the specific type
|
|
const API_CAST_TYPE_MAP = {
|
|
cookies: 'UnsafeUnwrappedCookies',
|
|
headers: 'UnsafeUnwrappedHeaders',
|
|
draftMode: 'UnsafeUnwrappedDraftMode',
|
|
}
|
|
|
|
function castTypesOrAddComment(
|
|
j: API['jscodeshift'],
|
|
path: ASTPath<CallExpression>,
|
|
originRequestApiName: string,
|
|
root: Collection<any>,
|
|
filePath: string,
|
|
insertedTypes: Set<string>,
|
|
customMessage: string
|
|
) {
|
|
let modified = false
|
|
const isTsFile = filePath.endsWith('.ts') || filePath.endsWith('.tsx')
|
|
if (isTsFile) {
|
|
// if the path of call expression is already being awaited, no need to cast
|
|
if (path.parentPath?.node?.type === 'AwaitExpression') return false
|
|
|
|
/* Do type cast for headers, cookies, draftMode
|
|
import {
|
|
type UnsafeUnwrappedHeaders,
|
|
type UnsafeUnwrappedCookies,
|
|
type UnsafeUnwrappedDraftMode
|
|
} from 'next/headers'
|
|
|
|
cookies() as unknown as UnsafeUnwrappedCookies
|
|
headers() as unknown as UnsafeUnwrappedHeaders
|
|
draftMode() as unknown as UnsafeUnwrappedDraftMode
|
|
|
|
e.g. `<path>` is cookies(), convert it to `(<path> as unknown as UnsafeUnwrappedCookies)`
|
|
*/
|
|
|
|
const targetType = API_CAST_TYPE_MAP[originRequestApiName]
|
|
const repairComment = ` ${NEXT_CODEMOD_ERROR_PREFIX} Await this API and update its callers; remove the temporary ${targetType} cast after repairing the migration. `
|
|
const parentCast = path.parentPath?.node
|
|
const outerCast = path.parentPath?.parentPath?.node
|
|
if (
|
|
j.TSAsExpression.check(parentCast) &&
|
|
j.TSAsExpression.check(outerCast) &&
|
|
j.TSTypeReference.check(outerCast.typeAnnotation) &&
|
|
j.Identifier.check(outerCast.typeAnnotation.typeName) &&
|
|
outerCast.typeAnnotation.typeName.name === targetType
|
|
) {
|
|
// Re-parsing attaches the leading marker to the outer cast.
|
|
return insertCommentOnce(outerCast, j, repairComment)
|
|
}
|
|
insertCommentOnce(path.node, j, repairComment)
|
|
|
|
const newCastExpression = j.tsAsExpression(
|
|
j.tsAsExpression(path.node, j.tsUnknownKeyword()),
|
|
j.tsTypeReference(j.identifier(targetType))
|
|
)
|
|
// Replace the original expression with the new cast expression,
|
|
// also wrap () around the new cast expression.
|
|
const parent = path.parent.value
|
|
const wrappedExpression = j.parenthesizedExpression(newCastExpression)
|
|
path.replace(wrappedExpression)
|
|
|
|
// If the wrapped expression `(<expression>)` is the beginning of an expression statement,
|
|
// add a void operator to separate the statement, to avoid syntax error that being treated as part of previous statement.
|
|
// example:
|
|
// input:
|
|
// <expression>
|
|
// <expression>
|
|
// output:
|
|
// (<expression> as ...)
|
|
// void (<expression> as ...)
|
|
if (
|
|
j.ExpressionStatement.check(parent) &&
|
|
parent.expression === path.node
|
|
) {
|
|
// append a semicolon to the start of the expression statement
|
|
parent.expression = j.unaryExpression('void', parent.expression)
|
|
}
|
|
modified = true
|
|
|
|
// If cast types are not imported, add them to the import list
|
|
const importDeclaration = root.find(j.ImportDeclaration, {
|
|
source: { value: 'next/headers' },
|
|
})
|
|
if (importDeclaration.size() > 0) {
|
|
const hasImportedType =
|
|
importDeclaration
|
|
.find(j.TSTypeAliasDeclaration, {
|
|
id: { name: targetType },
|
|
})
|
|
.size() > 0 ||
|
|
importDeclaration
|
|
.find(j.ImportSpecifier, {
|
|
imported: { name: targetType },
|
|
})
|
|
.size() > 0
|
|
|
|
if (!hasImportedType && !insertedTypes.has(targetType)) {
|
|
importDeclaration
|
|
.get()
|
|
.node.specifiers.push(
|
|
j.importSpecifier(j.identifier(`type ${targetType}`))
|
|
)
|
|
insertedTypes.add(targetType)
|
|
}
|
|
}
|
|
} else {
|
|
// Otherwise for JS file, leave a message to the user to manually handle the transformation
|
|
const inserted = insertCommentOnce(path.node, j, customMessage)
|
|
modified ||= inserted
|
|
}
|
|
|
|
return modified
|
|
}
|
|
|
|
function findImportMappingFromNextHeaders(root: Collection<any>, j: API['j']) {
|
|
const mappings = {}
|
|
|
|
// Find the import declaration from 'next/headers'
|
|
root
|
|
.find(j.ImportDeclaration, { source: { value: 'next/headers' } })
|
|
.forEach((importPath) => {
|
|
const importDeclaration = importPath.node
|
|
|
|
// Iterate over the specifiers and build the mappings
|
|
importDeclaration.specifiers.forEach((specifier) => {
|
|
if (j.ImportSpecifier.check(specifier)) {
|
|
const importedName = specifier.imported.name // Original name (e.g., cookies)
|
|
const localName = specifier.local.name // Local name (e.g., myCookies or same as importedName)
|
|
|
|
// Add to the mappings
|
|
mappings[importedName] = localName
|
|
}
|
|
})
|
|
})
|
|
|
|
return mappings
|
|
}
|