From 43d71ae01f7f6916a2a7756aaac9b6282ffcce59 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sun, 29 Sep 2024 20:41:06 +0900 Subject: [PATCH] resolve conflict with canary --- .../async-api-15.output.js | 2 +- .../async-api-16.output.js | 2 +- .../async-api-19.input.tsx | 13 + .../async-api-19.output.tsx | 13 + .../async-api-20.input.tsx | 7 + .../async-api-20.output.tsx | 7 + .../async-api-21.input.tsx | 7 + .../async-api-21.output.tsx | 8 + .../async-api-type-cast-02.output.js | 6 +- .../next-async-dynamic-api.ts | 115 +++-- .../next-async-dynamic-prop.ts | 430 +++++++++++++----- .../transforms/lib/async-request-api/utils.ts | 206 ++++++++- .../next-dynamic-access-named-export.ts | 2 +- 13 files changed, 657 insertions(+), 161 deletions(-) create mode 100644 packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-19.input.tsx create mode 100644 packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-19.output.tsx create mode 100644 packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-20.input.tsx create mode 100644 packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-20.output.tsx create mode 100644 packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-21.input.tsx create mode 100644 packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-21.output.tsx diff --git a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-15.output.js b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-15.output.js index 97a1d93ab112e..0e96c3c6544a9 100644 --- a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-15.output.js +++ b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-15.output.js @@ -2,7 +2,7 @@ import { cookies } from "next/headers"; async function MyComponent() { function asyncFunction() { - callSomething(/* TODO: please manually await this call, codemod cannot transform due to undetermined async scope */ + callSomething(/* TODO: please manually await this call, if it's a server component, you can turn it to async function */ cookies()); } } diff --git a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-16.output.js b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-16.output.js index 595ed638e866c..17a937050c0b9 100644 --- a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-16.output.js +++ b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-16.output.js @@ -1,6 +1,6 @@ import { cookies } from "next/headers"; function MyComponent() { - callSomething(/* TODO: please manually await this call, codemod cannot transform due to undetermined async scope */ + callSomething(/* TODO: please manually await this call, if it's a server component, you can turn it to async function */ cookies()); } diff --git a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-19.input.tsx b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-19.input.tsx new file mode 100644 index 0000000000000..52038ce568a6e --- /dev/null +++ b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-19.input.tsx @@ -0,0 +1,13 @@ +import { cookies, headers } from 'next/headers' + +export function myFun() { + return async function () { + cookies().get('name') + } +} + +export function myFun2() { + return function () { + headers() + } +} diff --git a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-19.output.tsx b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-19.output.tsx new file mode 100644 index 0000000000000..3fd23bb439c89 --- /dev/null +++ b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-19.output.tsx @@ -0,0 +1,13 @@ +import { cookies, headers, type UnsafeUnwrappedHeaders } from 'next/headers'; + +export function myFun() { + return async function () { + (await cookies()).get('name') + }; +} + +export function myFun2() { + return function () { + (headers() as unknown as UnsafeUnwrappedHeaders) + }; +} diff --git a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-20.input.tsx b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-20.input.tsx new file mode 100644 index 0000000000000..1fde86c0c3872 --- /dev/null +++ b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-20.input.tsx @@ -0,0 +1,7 @@ +import { cookies } from 'cookies' + +export function MyCls() { + return async function Page() { + return (await cookies()).get('token') + } +} diff --git a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-20.output.tsx b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-20.output.tsx new file mode 100644 index 0000000000000..1fde86c0c3872 --- /dev/null +++ b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-20.output.tsx @@ -0,0 +1,7 @@ +import { cookies } from 'cookies' + +export function MyCls() { + return async function Page() { + return (await cookies()).get('token') + } +} diff --git a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-21.input.tsx b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-21.input.tsx new file mode 100644 index 0000000000000..3b05d6129ef63 --- /dev/null +++ b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-21.input.tsx @@ -0,0 +1,7 @@ +const nextHeaders = import('next/headers') + +function myFunc() { + nextHeaders.cookies() +} + +const nextHeaders2 = /* The APIs under 'next/headers' are async now, need to be manually awaited. */ import('next/headers') \ No newline at end of file diff --git a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-21.output.tsx b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-21.output.tsx new file mode 100644 index 0000000000000..bf6e54ca0d54c --- /dev/null +++ b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-21.output.tsx @@ -0,0 +1,8 @@ +const nextHeaders = /* The APIs under 'next/headers' are async now, need to be manually awaited. */ +import('next/headers') + +function myFunc() { + nextHeaders.cookies() +} + +const nextHeaders2 = /* The APIs under 'next/headers' are async now, need to be manually awaited. */ import('next/headers') \ No newline at end of file diff --git a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-type-cast-02.output.js b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-type-cast-02.output.js index e74ef9caf98fc..2dcad2941b659 100644 --- a/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-type-cast-02.output.js +++ b/packages/next-codemod/src/transforms/__testfixtures__/next-async-request-api-dynamic-apis/async-api-type-cast-02.output.js @@ -6,7 +6,7 @@ import { } from 'next/headers' export function MyDraftComponent() { -if (/* TODO: please manually await this call, codemod cannot transform due to undetermined async scope */ +if (/* TODO: please manually await this call, if it's a server component, you can turn it to async function */ draftMode().isEnabled) { return null } @@ -15,13 +15,13 @@ draftMode().isEnabled) { } export function MyCookiesComponent() { - const c = /* TODO: please manually await this call, codemod cannot transform due to undetermined async scope */ + const c = /* TODO: please manually await this call, if it's a server component, you can turn it to async function */ cookies() return c.get('name') } export function MyHeadersComponent() { - const h = /* TODO: please manually await this call, codemod cannot transform due to undetermined async scope */ + const h = /* TODO: please manually await this call, if it's a server component, you can turn it to async function */ headers() return (

{h.get('x-foo')}

diff --git a/packages/next-codemod/src/transforms/lib/async-request-api/next-async-dynamic-api.ts b/packages/next-codemod/src/transforms/lib/async-request-api/next-async-dynamic-api.ts index 157d93a46507c..99346d3c56a25 100644 --- a/packages/next-codemod/src/transforms/lib/async-request-api/next-async-dynamic-api.ts +++ b/packages/next-codemod/src/transforms/lib/async-request-api/next-async-dynamic-api.ts @@ -5,14 +5,34 @@ import { isMatchedFunctionExported, turnFunctionReturnTypeToAsync, insertReactUseImport, + isFunctionScope, + findClosetParentFunctionScope, + wrapParentheseIfNeeded, + insertCommentOnce, } from './utils' -function wrapParathnessIfNeeded( - hasChainAccess: boolean, - j: API['jscodeshift'], - expression -) { - return hasChainAccess ? j.parenthesizedExpression(expression) : expression +const DYNAMIC_IMPORT_WARN_COMMENT = ` The APIs under 'next/headers' are async now, need to be manually awaited. ` + +function findDynamicImportsAndComment(root: Collection, 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) => { + insertCommentOnce(path, j, DYNAMIC_IMPORT_WARN_COMMENT) + modified = true + }) + return modified } export function transformDynamicAPI( @@ -55,22 +75,25 @@ export function transformDynamicAPI( if (!isImportedTopLevel) { return } + let parentFunctionPath = findClosetParentFunctionScope(path, j) - const closetScope = j(path).closestScope() - - // First search the closed function of the current path - let closestFunction: Collection - closestFunction = j(path).closest(j.FunctionDeclaration) - if (closestFunction.size() === 0) { - closestFunction = j(path).closest(j.FunctionExpression) - } - if (closestFunction.size() === 0) { - closestFunction = j(path).closest(j.ArrowFunctionExpression) + // 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 = closestFunction - .nodes() - .some((node) => node.async) + const isAsyncFunction = parentFunctionNode?.async || false const isCallAwaited = path.parentPath?.node?.type === 'AwaitExpression' @@ -78,6 +101,7 @@ export function transformDynamicAPI( 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) { @@ -86,7 +110,7 @@ export function transformDynamicAPI( // add parentheses to wrap the function call j.callExpression(j.identifier(asyncRequestApiName), []) ) - j(path).replaceWith(wrapParathnessIfNeeded(hasChainAccess, j, expr)) + j(path).replaceWith(wrapParentheseIfNeeded(hasChainAccess, j, expr)) modified = true } } else { @@ -134,7 +158,7 @@ export function transformDynamicAPI( j.callExpression(j.identifier(asyncRequestApiName), []) ) j(path).replaceWith( - wrapParathnessIfNeeded(hasChainAccess, j, expr) + wrapParentheseIfNeeded(hasChainAccess, j, expr) ) turnFunctionReturnTypeToAsync(closetScopePath.node, j) @@ -144,12 +168,9 @@ export function transformDynamicAPI( } } else { // if parent is function and it's a hook, which starts with 'use', wrap the api call with 'use()' - const parentFunction = - j(path).closest(j.FunctionDeclaration) || - j(path).closest(j.FunctionExpression) || - j(path).closest(j.ArrowFunctionExpression) + const parentFunction = findClosetParentFunctionScope(path, j) - if (parentFunction.size() > 0) { + if (parentFunction) { const parentFunctionName = parentFunction.get().node.id?.name const isParentFunctionHook = parentFunctionName?.startsWith('use') if (isParentFunctionHook) { @@ -166,7 +187,8 @@ export function transformDynamicAPI( originRequestApiName, root, filePath, - insertedTypes + insertedTypes, + ` TODO: please manually await this call, if it's a server component, you can turn it to async function ` ) } } else { @@ -176,7 +198,8 @@ export function transformDynamicAPI( originRequestApiName, root, filePath, - insertedTypes + insertedTypes, + ' TODO: please manually await this call, codemod cannot transform due to undetermined async scope ' ) } modified = true @@ -187,22 +210,26 @@ export function transformDynamicAPI( const isClientComponent = determineClientDirective(root, j, source) + // 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) + } - // Only transform the valid calls in server or shared components - if (!isClientComponent) { - 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) + } - // Add import { use } from 'react' if needed and not already imported - if (needsReactUseImport) { - insertReactUseImport(root, j) - } + if (findDynamicImportsAndComment(root, j)) { + modified = true } return modified ? root.toSource() : null @@ -217,14 +244,18 @@ const API_CAST_TYPE_MAP = { function castTypesOrAddComment( j: API['jscodeshift'], - path: ASTPath, + path: ASTPath, originRequestApiName: string, root: Collection, filePath: string, - insertedTypes: Set + insertedTypes: Set, + customMessage: string ) { 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 + /* Do type cast for headers, cookies, draftMode import { type UnsafeUnwrappedHeaders, @@ -278,9 +309,7 @@ function castTypesOrAddComment( } else { // Otherwise for JS file, leave a message to the user to manually handle the transformation path.node.comments = [ - j.commentBlock( - ' TODO: please manually await this call, codemod cannot transform due to undetermined async scope ' - ), + j.commentBlock(customMessage), ...(path.node.comments || []), ] } diff --git a/packages/next-codemod/src/transforms/lib/async-request-api/next-async-dynamic-prop.ts b/packages/next-codemod/src/transforms/lib/async-request-api/next-async-dynamic-prop.ts index 3b1ed9c52d177..c9b74dfcd8b23 100644 --- a/packages/next-codemod/src/transforms/lib/async-request-api/next-async-dynamic-prop.ts +++ b/packages/next-codemod/src/transforms/lib/async-request-api/next-async-dynamic-prop.ts @@ -2,36 +2,130 @@ import type { API, Collection, ASTPath, - ExportDefaultDeclaration, - ExportNamedDeclaration, ObjectPattern, + Identifier, } from 'jscodeshift' import { determineClientDirective, generateUniqueIdentifier, + getFunctionPathFromExportPath, insertReactUseImport, isFunctionType, TARGET_NAMED_EXPORTS, TARGET_PROP_NAMES, turnFunctionReturnTypeToAsync, + wrapParentheseIfNeeded, + type FunctionScope, } from './utils' const PAGE_PROPS = 'props' -type FunctionalExportDeclaration = - | ExportDefaultDeclaration - | ExportNamedDeclaration +function findFunctionBody(path: ASTPath) { + let functionBody = path.node.body + if (functionBody && functionBody.type === 'BlockStatement') { + return functionBody.body + } + return null +} + +function awaitMemberAccessOfProp( + propIdName: string, + path: ASTPath, + j: API['jscodeshift'] +) { + // search the member access of the prop + const functionBody = findFunctionBody(path) + const memberAccess = j(functionBody).find(j.MemberExpression, { + object: { + type: 'Identifier', + name: propIdName, + }, + }) + + let hasAwaited = false + // await each member access + memberAccess.forEach((memberAccessPath) => { + const member = memberAccessPath.value + + // check if it's already awaited + if (memberAccessPath.parentPath?.value.type === 'AwaitExpression') { + return + } + const awaitedExpr = j.awaitExpression(member) + + const awaitMemberAccess = wrapParentheseIfNeeded(true, j, awaitedExpr) + memberAccessPath.replace(awaitMemberAccess) + hasAwaited = true + }) + + // If there's any awaited member access, we need to make the function async + if (hasAwaited) { + if (!path.value.async) { + if ('async' in path.value) { + path.value.async = true + turnFunctionReturnTypeToAsync(path.value, j) + } + } + } + return hasAwaited +} -function isAsyncFunctionDeclaration( - path: ASTPath +function applyUseAndRenameAccessedProp( + propIdName: string, + path: ASTPath, + j: API['jscodeshift'] ) { - const decl = path.value.declaration - const isAsyncFunction = - (decl.type === 'FunctionDeclaration' || - decl.type === 'FunctionExpression' || - decl.type === 'ArrowFunctionExpression') && - decl.async - return isAsyncFunction + // search the member access of the prop, and rename the member access to the member value + // e.g. + // props.params => params + // props.params.foo => params.foo + // props.searchParams.search => searchParams.search + let modified = false + const functionBody = findFunctionBody(path) + const memberAccess = j(functionBody).find(j.MemberExpression, { + object: { + type: 'Identifier', + name: propIdName, + }, + }) + + const accessedNames: string[] = [] + // rename each member access + memberAccess.forEach((memberAccessPath) => { + const member = memberAccessPath.value + const memberProperty = member.property + if (j.Identifier.check(memberProperty)) { + accessedNames.push(memberProperty.name) + } else if (j.MemberExpression.check(memberProperty)) { + let currentMember = memberProperty + if (j.Identifier.check(currentMember.object)) { + accessedNames.push(currentMember.object.name) + } + } + memberAccessPath.replace(memberProperty) + }) + + // If there's any renamed member access, need to call `use()` onto member access + // e.g. ['params'] => insert `const params = use(props.params)` + if (accessedNames.length > 0) { + const accessedPropId = j.identifier(propIdName) + const accessedProp = j.memberExpression( + accessedPropId, + j.identifier(accessedNames[0]) + ) + + const useCall = j.callExpression(j.identifier('use'), [accessedProp]) + const useDeclaration = j.variableDeclaration('const', [ + j.variableDeclarator(j.identifier(accessedNames[0]), useCall), + ]) + + if (functionBody) { + functionBody.unshift(useDeclaration) + } + + modified = true + } + return modified } export function transformDynamicProps( @@ -40,6 +134,7 @@ export function transformDynamicProps( _filePath: string ) { let modified = false + let modifiedPropArgument = false const j = api.jscodeshift.withParser('tsx') const root = j(source) // Check if 'use' from 'react' needs to be imported @@ -54,23 +149,22 @@ export function transformDynamicProps( function processAsyncPropOfEntryFile(isClientComponent: boolean) { // find `params` and `searchParams` in file, and transform the access to them function renameAsyncPropIfExisted( - path: ASTPath + path: ASTPath, + isDefaultExport: boolean ) { - const decl = path.value.declaration - if ( - decl.type !== 'FunctionDeclaration' && - decl.type !== 'FunctionExpression' && - decl.type !== 'ArrowFunctionExpression' - ) { - return - } - + const decl = path.value const params = decl.params + const functionName = decl.id?.name || 'default' + // target properties mapping, only contains `params` and `searchParams` const propertiesMap = new Map() - - // If there's no first param, return - if (params.length !== 1) { - return + let allProperties: ObjectPattern['properties'] = [] + + // generateMetadata API has 2 params + if (functionName === 'generateMetadata') { + if (params.length > 2 || params.length === 0) return + } else { + // Page/Layout/Route handlers have 1 param + if (params.length !== 1) return } const propsIdentifier = generateUniqueIdentifier(PAGE_PROPS, path, j) @@ -80,20 +174,27 @@ export function transformDynamicProps( if (currentParam.type === 'ObjectPattern') { // Validate if the properties are not `params` and `searchParams`, // if they are, quit the transformation + let foundTargetProp = false for (const prop of currentParam.properties) { if ('key' in prop && prop.key.type === 'Identifier') { const propName = prop.key.name - if (!TARGET_PROP_NAMES.has(propName)) { - return + if (TARGET_PROP_NAMES.has(propName)) { + foundTargetProp = true } } } + // If there's no `params` or `searchParams` matched, return + if (!foundTargetProp) return + + allProperties = currentParam.properties + currentParam.properties.forEach((prop) => { if ( // Could be `Property` or `ObjectProperty` 'key' in prop && - prop.key.type === 'Identifier' + prop.key.type === 'Identifier' && + TARGET_PROP_NAMES.has(prop.key.name) ) { const value = 'value' in prop ? prop.value : null propertiesMap.set(prop.key.name, value) @@ -210,59 +311,182 @@ export function transformDynamicProps( params[0] = propsIdentifier modified = true + modifiedPropArgument = true + } else if (currentParam.type === 'Identifier') { + // case of accessing the props.params.: + // Page(props) {} + // generateMetadata(props, parent?) {} + const argName = currentParam.name + + if (isClientComponent) { + const modifiedProp = applyUseAndRenameAccessedProp(argName, path, j) + if (modifiedProp) { + needsReactUseImport = true + modified = true + } + } else { + modified = awaitMemberAccessOfProp(argName, path, j) + } + + // cases of passing down `props` into any function + // Page(props) { callback(props) } + + // search for all the argument of CallExpression, where currentParam is one of the arguments + const callExpressions = j(path).find(j.CallExpression, { + arguments: (args) => { + return args.some((arg) => { + return ( + j.Identifier.check(arg) && + arg.name === argName && + arg.type === 'Identifier' + ) + }) + }, + }) + + // Add a comment to warn users that properties of `props` need to be awaited when accessed + callExpressions.forEach((callExpression) => { + // find the argument `currentParam` + const args = callExpression.value.arguments + const propPassedAsArg = args.find( + (arg) => j.Identifier.check(arg) && arg.name === argName + ) + // insert a comment to the argument + const comment = j.commentBlock( + ` '${argName}' is passed as an argument. Any asynchronous properties of 'props' must be awaited when accessed. `, + true, + false + ) + propPassedAsArg.comments = [ + comment, + ...(propPassedAsArg.comments || []), + ] + modified = true + }) } - if (modified) { - resolveAsyncProp(path, propertiesMap, propsIdentifier.name) + if (modifiedPropArgument) { + resolveAsyncProp( + path, + propertiesMap, + propsIdentifier.name, + allProperties, + isDefaultExport + ) } } - function getBodyOfFunctionDeclaration( - path: ASTPath + // Helper function to insert `const params = await asyncParams;` at the beginning of the function body + function resolveAsyncProp( + path: ASTPath, + propertiesMap: Map, + propsIdentifierName: string, + allProperties: ObjectPattern['properties'], + isDefaultExport: boolean ) { - const decl = path.value.declaration + const node = path.value - let functionBody - if ( - decl.type === 'FunctionDeclaration' || - decl.type === 'FunctionExpression' || - decl.type === 'ArrowFunctionExpression' - ) { - if (decl.body && decl.body.type === 'BlockStatement') { - functionBody = decl.body.body + // If it's sync default export, and it's also server component, make the function async + if (isDefaultExport && !isClientComponent) { + if (!node.async) { + if ('async' in node) { + node.async = true + turnFunctionReturnTypeToAsync(node, j) + } } } - return functionBody - } + const isAsyncFunc = !!node.async + const functionName = path.value.id?.name || 'default' + const functionBody = findFunctionBody(path) + const hasOtherProperties = allProperties.length > propertiesMap.size - // Helper function to insert `const params = await asyncParams;` at the beginning of the function body - function resolveAsyncProp( - path: ASTPath, - propertiesMap: Map, - propsIdentifierName: string - ) { - const isDefaultExport = path.value.type === 'ExportDefaultDeclaration' - // If it's sync default export, and it's also server component, make the function async - if ( - isDefaultExport && - !isClientComponent && - !isAsyncFunctionDeclaration(path) + function createDestructuringDeclaration( + properties: ObjectPattern['properties'], + destructPropsIdentifierName: string ) { - if ('async' in path.value.declaration) { - path.value.declaration.async = true - turnFunctionReturnTypeToAsync(path.value.declaration, j) + const propsToKeep = [] + let restProperty = null + + // Iterate over the destructured properties + properties.forEach((property) => { + if (j.ObjectProperty.check(property)) { + // Handle normal and computed properties + const keyName = j.Identifier.check(property.key) + ? property.key.name + : j.Literal.check(property.key) + ? property.key.value + : null // for computed properties + + if (typeof keyName === 'string') { + propsToKeep.push(property) + } + } else if (j.RestElement.check(property)) { + restProperty = property + } + }) + + if (propsToKeep.length === 0 && !restProperty) { + return null + } + + if (restProperty) { + propsToKeep.push(restProperty) + } + + return j.variableDeclaration('const', [ + j.variableDeclarator( + j.objectPattern(propsToKeep), + j.identifier(destructPropsIdentifierName) + ), + ]) + } + + if (hasOtherProperties) { + /** + * If there are other properties, we need to keep the original param with destructuring + * e.g. + * input: + * Page({ params: { slug }, otherProp }) { + * const { slug } = await props.params; + * } + * + * output: + * Page(props) { + * const { otherProp } = props; // inserted + * // ...rest of the function body + * } + */ + const restProperties = allProperties.filter((prop) => { + const isTargetProps = + 'key' in prop && + prop.key.type === 'Identifier' && + TARGET_PROP_NAMES.has(prop.key.name) + return !isTargetProps + }) + const destructionOtherPropertiesDeclaration = + createDestructuringDeclaration(restProperties, propsIdentifierName) + if (functionBody && destructionOtherPropertiesDeclaration) { + functionBody.unshift(destructionOtherPropertiesDeclaration) } } - const isAsyncFunc = isAsyncFunctionDeclaration(path) - // @ts-ignore quick way to check if it's a function and it has a name - const functionName = path.value.declaration.id?.name || 'default' + for (const [matchedPropName, paramsProperty] of propertiesMap) { + if (!TARGET_PROP_NAMES.has(matchedPropName)) { + continue + } + + const propRenamedId = j.Identifier.check(paramsProperty) + ? paramsProperty.name + : null + const propName = propRenamedId || matchedPropName - const functionBody = getBodyOfFunctionDeclaration(path) + // if propName is not used in lower scope, and it stars with unused prefix `_`, + // also skip the transformation + const hasDeclared = path.scope.declares(propName) + if (!hasDeclared && propName.startsWith('_')) continue - for (const [propName, paramsProperty] of propertiesMap) { - const propNameIdentifier = j.identifier(propName) + const propNameIdentifier = j.identifier(matchedPropName) const propsIdentifier = j.identifier(propsIdentifierName) const accessedPropId = j.memberExpression( propsIdentifier, @@ -319,16 +543,16 @@ export function transformDynamicProps( insertedRenamedPropFunctionNames.add(uid) } } else { - const isFromExport = path.value.type === 'ExportNamedDeclaration' - if (isFromExport) { + // const isFromExport = true + if (!isClientComponent) { // If it's export function, populate the function to async if ( - isFunctionType(path.value.declaration.type) && + isFunctionType(node.type) && // Make TS happy - 'async' in path.value.declaration + 'async' in node ) { - path.value.declaration.async = true - turnFunctionReturnTypeToAsync(path.value.declaration, j) + node.async = true + turnFunctionReturnTypeToAsync(node, j) // Insert `const = await props.;` at the beginning of the function body const paramAssignment = j.variableDeclaration('const', [ @@ -359,51 +583,37 @@ export function transformDynamicProps( } } - // Process Function Declarations - // Matching: default export function XXX(...) { ... } - const defaultExportFunctionDeclarations = root.find( - j.ExportDefaultDeclaration, - { - declaration: { - type: (type) => - type === 'FunctionDeclaration' || - type === 'FunctionExpression' || - type === 'ArrowFunctionExpression', - }, + const defaultExportsDeclarations = root.find(j.ExportDefaultDeclaration) + + defaultExportsDeclarations.forEach((path) => { + const functionPath = getFunctionPathFromExportPath( + path, + j, + root, + () => true + ) + if (functionPath) { + renameAsyncPropIfExisted(functionPath, true) } - ) - - defaultExportFunctionDeclarations.forEach((path) => { - renameAsyncPropIfExisted(path) }) // Matching Next.js functional named export of route entry: // - export function (...) { ... } // - export const = ... - const targetNamedExportDeclarations = root.find( - j.ExportNamedDeclaration, - // Filter the name is in TARGET_NAMED_EXPORTS - { - declaration: { - id: { - name: (idName: string) => { - return TARGET_NAMED_EXPORTS.has(idName) - }, - }, - }, + const namedExportDeclarations = root.find(j.ExportNamedDeclaration) + + namedExportDeclarations.forEach((path) => { + const functionPath = getFunctionPathFromExportPath( + path, + j, + root, + (idName) => TARGET_NAMED_EXPORTS.has(idName) + ) + + if (functionPath) { + renameAsyncPropIfExisted(functionPath, false) } - ) - - targetNamedExportDeclarations.forEach((path) => { - renameAsyncPropIfExisted(path) }) - // TDOO: handle targetNamedDeclarators - // const targetNamedDeclarators = root.find( - // j.VariableDeclarator, - // (node) => - // node.id.type === 'Identifier' && - // TARGET_NAMED_EXPORTS.has(node.id.name) - // ) } const isClientComponent = determineClientDirective(root, j, source) diff --git a/packages/next-codemod/src/transforms/lib/async-request-api/utils.ts b/packages/next-codemod/src/transforms/lib/async-request-api/utils.ts index 36eab775654a3..ef7e25504ffdb 100644 --- a/packages/next-codemod/src/transforms/lib/async-request-api/utils.ts +++ b/packages/next-codemod/src/transforms/lib/async-request-api/utils.ts @@ -1,12 +1,20 @@ import type { API, + ArrowFunctionExpression, ASTPath, Collection, + ExportDefaultDeclaration, + ExportNamedDeclaration, FunctionDeclaration, + FunctionExpression, JSCodeshift, - Node, } from 'jscodeshift' +export type FunctionScope = + | FunctionDeclaration + | FunctionExpression + | ArrowFunctionExpression + export const TARGET_NAMED_EXPORTS = new Set([ // For custom route 'GET', @@ -153,7 +161,7 @@ export function isPromiseType(typeAnnotation) { } export function turnFunctionReturnTypeToAsync( - node: Node, + node: any, j: API['jscodeshift'] ) { if ( @@ -265,3 +273,197 @@ export function generateUniqueIdentifier( const propsIdentifier = j.identifier(idName) return propsIdentifier } + +export function isFunctionScope( + path: ASTPath, + j: API['jscodeshift'] +): path is ASTPath { + if (!path) return false + const node = path.node + + // Check if the node is a function (declaration, expression, or arrow function) + return ( + j.FunctionDeclaration.check(node) || + j.FunctionExpression.check(node) || + j.ArrowFunctionExpression.check(node) + ) +} + +export function findClosetParentFunctionScope( + path: ASTPath, + j: API['jscodeshift'] +) { + let parentFunctionPath = path.scope.path + while (parentFunctionPath && !isFunctionScope(parentFunctionPath, j)) { + parentFunctionPath = parentFunctionPath.parent + } + + return parentFunctionPath +} + +function getFunctionNodeFromBinding( + bindingPath: ASTPath, + idName: string, + j: API['jscodeshift'], + root: Collection +): ASTPath | undefined { + const bindingNode = bindingPath.node + if ( + j.FunctionDeclaration.check(bindingNode) || + j.FunctionExpression.check(bindingNode) || + j.ArrowFunctionExpression.check(bindingNode) + ) { + return bindingPath + } else if (j.VariableDeclarator.check(bindingNode)) { + const init = bindingNode.init + // If the initializer is a function (arrow or function expression), record it + if ( + j.FunctionExpression.check(init) || + j.ArrowFunctionExpression.check(init) + ) { + return bindingPath.get('init') + } + } else if (j.Identifier.check(bindingNode)) { + const variablePath = root.find(j.VariableDeclaration, { + // declarations, each is VariableDeclarator + declarations: [ + { + // VariableDeclarator + type: 'VariableDeclarator', + // id is Identifier + id: { + type: 'Identifier', + name: idName, + }, + }, + ], + }) + + if (variablePath.size()) { + const variableDeclarator = variablePath.get()?.node?.declarations?.[0] + if (j.VariableDeclarator.check(variableDeclarator)) { + const init = variableDeclarator.init + if ( + j.FunctionExpression.check(init) || + j.ArrowFunctionExpression.check(init) + ) { + return variablePath.get('declarations', 0, 'init') + } + } + } + + const functionDeclarations = root.find(j.FunctionDeclaration, { + id: { + name: idName, + }, + }) + if (functionDeclarations.size()) { + return functionDeclarations.get() + } + } + return undefined +} + +export function getFunctionPathFromExportPath( + exportPath: ASTPath, + j: API['jscodeshift'], + root: Collection, + namedExportFilter: (idName: string) => boolean +): ASTPath | undefined { + // Default export + if (j.ExportDefaultDeclaration.check(exportPath.node)) { + const { declaration } = exportPath.node + if (declaration) { + if ( + j.FunctionDeclaration.check(declaration) || + j.FunctionExpression.check(declaration) || + j.ArrowFunctionExpression.check(declaration) + ) { + return exportPath.get('declaration') + } else if (j.Identifier.check(declaration)) { + const idName = declaration.name + if (!namedExportFilter(idName)) return + + const exportBinding = exportPath.scope.getBindings()[idName]?.[0] + if (exportBinding) { + return getFunctionNodeFromBinding(exportBinding, idName, j, root) + } + } + } + } else if ( + // Named exports + j.ExportNamedDeclaration.check(exportPath.node) + ) { + const namedExportPath = exportPath as ASTPath + // extract the named exports, name specifiers, and default specifiers + const { declaration, specifiers } = namedExportPath.node + if (declaration) { + if (j.VariableDeclaration.check(declaration)) { + const { declarations } = declaration + for (const decl of declarations) { + if (j.VariableDeclarator.check(decl) && j.Identifier.check(decl.id)) { + const idName = decl.id.name + + if (!namedExportFilter(idName)) return + + // get bindings for each variable declarator + const exportBinding = + namedExportPath.scope.getBindings()[idName]?.[0] + if (exportBinding) { + return getFunctionNodeFromBinding(exportBinding, idName, j, root) + } + } + } + } else if ( + j.FunctionDeclaration.check(declaration) || + j.FunctionExpression.check(declaration) || + j.ArrowFunctionExpression.check(declaration) + ) { + const funcName = declaration.id?.name + if (!namedExportFilter(funcName)) return + + return namedExportPath.get('declaration') + } + } + if (specifiers) { + for (const specifier of specifiers) { + if (j.ExportSpecifier.check(specifier)) { + const idName = specifier.local.name + + if (!namedExportFilter(idName)) return + + const exportBinding = namedExportPath.scope.getBindings()[idName]?.[0] + if (exportBinding) { + return getFunctionNodeFromBinding(exportBinding, idName, j, root) + } + } + } + } + } + + return undefined +} + +export function wrapParentheseIfNeeded( + hasChainAccess: boolean, + j: API['jscodeshift'], + expression +) { + return hasChainAccess ? j.parenthesizedExpression(expression) : expression +} + +export function insertCommentOnce( + path: ASTPath, + j: API['j'], + comment: string +) { + if (path.node.comments) { + const hasComment = path.node.comments.some( + (commentNode) => commentNode.value === comment + ) + if (hasComment) { + return + } + } + path.node.comments = [j.commentBlock(comment), ...(path.node.comments || [])] +} diff --git a/packages/next-codemod/src/transforms/next-dynamic-access-named-export.ts b/packages/next-codemod/src/transforms/next-dynamic-access-named-export.ts index 25064f1d5374a..0e96b15e3951b 100644 --- a/packages/next-codemod/src/transforms/next-dynamic-access-named-export.ts +++ b/packages/next-codemod/src/transforms/next-dynamic-access-named-export.ts @@ -15,7 +15,7 @@ export default function transformer(file: FileInfo, api: API) { const dynamicImportName = importDecl.specifiers?.[0]?.local?.name if (!dynamicImportName) { - return root.toSource() + return file.source } // Find call expressions where the callee is the imported 'dynamic' root