| | import type { API, ASTPath, CallExpression, Collection } from 'jscodeshift' |
| | import { |
| | determineClientDirective, |
| | isFunctionType, |
| | isMatchedFunctionExported, |
| | turnFunctionReturnTypeToAsync, |
| | insertReactUseImport, |
| | isFunctionScope, |
| | findClosetParentFunctionScope, |
| | wrapParentheseIfNeeded, |
| | insertCommentOnce, |
| | NEXTJS_ENTRY_FILES, |
| | NEXT_CODEMOD_ERROR_PREFIX, |
| | containsReactHooksCallExpressions, |
| | isParentUseCallExpression, |
| | isReactHookName, |
| | } from './utils' |
| | import { createParserFromPath } from '../../../lib/parser' |
| |
|
| | const DYNAMIC_IMPORT_WARN_COMMENT = ` @next-codemod-error The APIs under 'next/headers' are async now, need to be manually awaited. ` |
| |
|
| | function findDynamicImportsAndComment(root: Collection<any>, j: API['j']) { |
| | let modified = false |
| | |
| | |
| |
|
| | |
| | |
| | |
| | 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 |
| |
|
| | |
| | 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 |
| | ) { |
| | |
| | root |
| | .find(j.CallExpression, { |
| | callee: { |
| | type: 'Identifier', |
| | name: asyncRequestApiName, |
| | }, |
| | }) |
| | .forEach((path) => { |
| | const isImportedTopLevel = isImportedInModule(path, asyncRequestApiName) |
| | if (!isImportedTopLevel) { |
| | return |
| | } |
| | let parentFunctionPath = findClosetParentFunctionScope(path, j) |
| | |
| | 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() |
| | |
| | if (isAsyncFunction) { |
| | if (!isCallAwaited) { |
| | |
| | const expr = j.awaitExpression( |
| | |
| | j.callExpression(j.identifier(asyncRequestApiName), []) |
| | ) |
| | j(path).replaceWith(wrapParentheseIfNeeded(hasChainAccess, j, expr)) |
| | modified = true |
| | } |
| | } else { |
| | |
| | const closetScopePath = closetScope.get() |
| | const isEntryFileExport = |
| | isEntryFile && isMatchedFunctionExported(closetScopePath, j) |
| | const closestFunctionNode = closetScope.size() |
| | ? closetScopePath.node |
| | : null |
| |
|
| | |
| | |
| | |
| | |
| | let exportFunctionNode |
| |
|
| | if (isEntryFileExport) { |
| | if ( |
| | closestFunctionNode && |
| | isFunctionType(closestFunctionNode.type) |
| | ) { |
| | exportFunctionNode = closestFunctionNode |
| | } |
| | } else { |
| | |
| | exportFunctionNode = closestFunctionNode |
| | } |
| |
|
| | let canConvertToAsync = false |
| | |
| | if (isEntryFileExport) { |
| | |
| | if (!isCallAwaited && isFunctionType(exportFunctionNode.type)) { |
| | const hasReactHooksUsage = containsReactHooksCallExpressions( |
| | closetScopePath, |
| | j |
| | ) |
| | |
| | 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 (!isParentUseCallExpression(path, j)) { |
| | j(path).replaceWith( |
| | j.callExpression(j.identifier('use'), [ |
| | j.callExpression(j.identifier(asyncRequestApiName), []), |
| | ]) |
| | ) |
| | needsReactUseImport = true |
| | modified = true |
| | } |
| | } |
| | } |
| | } else { |
| | |
| | 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 |
| | } |
| | } |
| | } |
| | }) |
| |
|
| | |
| | |
| | root |
| | .find(j.TSTypeReference, { |
| | typeName: { |
| | type: 'Identifier', |
| | name: 'ReturnType', |
| | }, |
| | }) |
| | .forEach((path) => { |
| | const typeParam = path.node.typeParameters?.params[0] |
| |
|
| | |
| | if ( |
| | typeParam && |
| | j.TSTypeQuery.check(typeParam) && |
| | j.Identifier.check(typeParam.exprName) && |
| | typeParam.exprName.name === asyncRequestApiName |
| | ) { |
| | |
| | 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) |
| |
|
| | |
| | if (isClientComponent) return null |
| |
|
| | |
| | const importedNextAsyncRequestApisMapping = findImportMappingFromNextHeaders( |
| | root, |
| | j |
| | ) |
| | for (const originName in importedNextAsyncRequestApisMapping) { |
| | const aliasName = importedNextAsyncRequestApisMapping[originName] |
| | processAsyncApiCalls(aliasName, originName) |
| | } |
| |
|
| | |
| | if (needsReactUseImport) { |
| | insertReactUseImport(root, j) |
| | } |
| |
|
| | const commented = findDynamicImportsAndComment(root, j) |
| | modified ||= commented |
| |
|
| | return modified ? root.toSource() : null |
| | } |
| |
|
| | |
| | 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 (path.parentPath?.node?.type === 'AwaitExpression') return false |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | const targetType = API_CAST_TYPE_MAP[originRequestApiName] |
| |
|
| | const newCastExpression = j.tsAsExpression( |
| | j.tsAsExpression(path.node, j.tsUnknownKeyword()), |
| | j.tsTypeReference(j.identifier(targetType)) |
| | ) |
| | |
| | |
| | const parent = path.parent.value |
| | const wrappedExpression = j.parenthesizedExpression(newCastExpression) |
| | path.replace(wrappedExpression) |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | if ( |
| | j.ExpressionStatement.check(parent) && |
| | parent.expression === path.node |
| | ) { |
| | |
| | parent.expression = j.unaryExpression('void', parent.expression) |
| | } |
| | modified = true |
| |
|
| | |
| | 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 { |
| | |
| | const inserted = insertCommentOnce(path.node, j, customMessage) |
| | modified ||= inserted |
| | } |
| |
|
| | return modified |
| | } |
| |
|
| | function findImportMappingFromNextHeaders(root: Collection<any>, j: API['j']) { |
| | const mappings = {} |
| |
|
| | |
| | root |
| | .find(j.ImportDeclaration, { source: { value: 'next/headers' } }) |
| | .forEach((importPath) => { |
| | const importDeclaration = importPath.node |
| |
|
| | |
| | importDeclaration.specifiers.forEach((specifier) => { |
| | if (j.ImportSpecifier.check(specifier)) { |
| | const importedName = specifier.imported.name |
| | const localName = specifier.local.name |
| |
|
| | |
| | mappings[importedName] = localName |
| | } |
| | }) |
| | }) |
| |
|
| | return mappings |
| | } |
| |
|