next.js/packages/next-codemod/transforms/remove-unstable-prefix.ts
remove-unstable-prefix.ts358 lines13.4 KB
import type { API, FileInfo, Options } from 'jscodeshift'
import { createParserFromPath } from '../lib/parser'

// Mapping of unstable APIs to their stable counterparts
// This can be easily extended when new APIs are stabilized
const UNSTABLE_TO_STABLE_MAPPING: Record<string, string> = {
  unstable_cacheTag: 'cacheTag',
  unstable_cacheLife: 'cacheLife',
}

// Helper function to check if a property name should be renamed
function shouldRenameProperty(propertyName: string): boolean {
  return propertyName in UNSTABLE_TO_STABLE_MAPPING
}

export default function transformer(
  file: FileInfo,
  _api: API,
  options: Options
) {
  const j = createParserFromPath(file.path)
  const root = j(file.source)
  let hasChanges = false

  try {
    // Track identifier renames that need to be applied
    const identifierRenames: Array<{ oldName: string; newName: string }> = []
    // Track variables assigned from next/cache imports/requires
    const cacheVariables = new Set<string>()

    // Handle ES6 imports: import { unstable_cacheTag } from 'next/cache'
    root
      .find(j.ImportDeclaration, { source: { value: 'next/cache' } })
      .forEach((path) => {
        path.node.specifiers?.forEach((specifier) => {
          if (
            specifier.type === 'ImportSpecifier' &&
            specifier.imported?.type === 'Identifier' &&
            shouldRenameProperty(specifier.imported.name)
          ) {
            const oldName = specifier.imported.name
            const newName = UNSTABLE_TO_STABLE_MAPPING[oldName]

            // Handle alias scenarios
            if (specifier.local && specifier.local.name === newName) {
              // Same alias name: { unstable_cacheTag as cacheTag } -> { cacheTag }
              const newSpecifier = j.importSpecifier(j.identifier(newName))
              const specifierIndex = path.node.specifiers.indexOf(specifier)
              path.node.specifiers[specifierIndex] = newSpecifier
              identifierRenames.push({ oldName, newName })
            } else {
              // Normal case: just update the imported name
              specifier.imported = j.identifier(newName)
              if (!specifier.local || specifier.local.name === oldName) {
                // Not aliased or aliased with old name: add to identifier renames
                identifierRenames.push({ oldName, newName })
              }
            }

            hasChanges = true
          } else if (specifier.type === 'ImportNamespaceSpecifier') {
            // Handle namespace imports: import * as cache from 'next/cache'
            cacheVariables.add(specifier.local.name)
          }
        })
      })

    // Handle export statements: export { unstable_cacheTag } from 'next/cache'
    root
      .find(j.ExportNamedDeclaration, { source: { value: 'next/cache' } })
      .forEach((path) => {
        path.node.specifiers?.forEach((specifier) => {
          if (
            specifier.type === 'ExportSpecifier' &&
            specifier.local?.type === 'Identifier' &&
            shouldRenameProperty(specifier.local.name)
          ) {
            const oldName = specifier.local.name
            const newName = UNSTABLE_TO_STABLE_MAPPING[oldName]

            specifier.local = j.identifier(newName)

            // Handle export alias scenarios
            if (specifier.exported && specifier.exported.name === newName) {
              // Same alias name: { unstable_cacheTag as cacheTag } -> { cacheTag }
              specifier.exported = specifier.local
            } else if (
              !specifier.exported ||
              specifier.exported.name === oldName
            ) {
              // Not aliased or aliased with old name
              specifier.exported = j.identifier(newName)
            }

            hasChanges = true
          }
        })
      })

    // Handle require('next/cache') calls and destructuring
    root
      .find(j.CallExpression, { callee: { name: 'require' } })
      .forEach((path) => {
        if (
          path.node.arguments[0]?.type === 'StringLiteral' &&
          path.node.arguments[0].value === 'next/cache'
        ) {
          // Track variable assignments: const cache = require('next/cache')
          const parent = path.parent?.node
          if (
            parent?.type === 'VariableDeclarator' &&
            parent.id?.type === 'Identifier'
          ) {
            cacheVariables.add(parent.id.name)
          }

          // Handle destructuring: const { unstable_cacheTag } = require('next/cache')
          if (
            parent?.type === 'VariableDeclarator' &&
            parent.id?.type === 'ObjectPattern'
          ) {
            parent.id.properties?.forEach((property) => {
              if (
                property.type === 'ObjectProperty' &&
                property.key?.type === 'Identifier' &&
                shouldRenameProperty(property.key.name)
              ) {
                const oldName = property.key.name
                const newName = UNSTABLE_TO_STABLE_MAPPING[oldName]

                property.key = j.identifier(newName)

                // Handle both shorthand and explicit destructuring
                if (!property.value) {
                  property.value = j.identifier(newName)
                  identifierRenames.push({ oldName, newName })
                } else if (property.value.type === 'Identifier') {
                  const localName = property.value.name
                  if (localName === oldName) {
                    property.value = j.identifier(newName)
                    identifierRenames.push({ oldName, newName })
                  } else if (localName === newName) {
                    // Same alias name: { unstable_cacheTag: cacheTag } -> { cacheTag }
                    property.value = j.identifier(newName)
                    property.shorthand = true
                    identifierRenames.push({ oldName, newName })
                  }
                }

                hasChanges = true
              }
            })
          }
        }
      })

    // Handle await import('next/cache') calls and destructuring
    root.find(j.AwaitExpression).forEach((path) => {
      const arg = path.node.argument
      if (
        arg?.type === 'CallExpression' &&
        arg.callee?.type === 'Import' &&
        arg.arguments[0]?.type === 'StringLiteral' &&
        arg.arguments[0].value === 'next/cache'
      ) {
        // Track variable assignments: const cache = await import('next/cache')
        const parent = path.parent?.node
        if (
          parent?.type === 'VariableDeclarator' &&
          parent.id?.type === 'Identifier'
        ) {
          cacheVariables.add(parent.id.name)
        }

        // Handle destructuring: const { unstable_cacheTag } = await import('next/cache')
        if (
          parent?.type === 'VariableDeclarator' &&
          parent.id?.type === 'ObjectPattern'
        ) {
          parent.id.properties?.forEach((property) => {
            if (
              property.type === 'ObjectProperty' &&
              property.key?.type === 'Identifier' &&
              shouldRenameProperty(property.key.name)
            ) {
              const oldName = property.key.name
              const newName = UNSTABLE_TO_STABLE_MAPPING[oldName]

              property.key = j.identifier(newName)

              if (!property.value) {
                property.value = j.identifier(newName)
                identifierRenames.push({ oldName, newName })
              } else if (property.value.type === 'Identifier') {
                const localName = property.value.name
                if (localName === oldName) {
                  property.value = j.identifier(newName)
                  identifierRenames.push({ oldName, newName })
                } else if (localName === newName) {
                  // Same alias name: { unstable_cacheTag: cacheTag } -> { cacheTag }
                  property.value = j.identifier(newName)
                  property.shorthand = true
                  identifierRenames.push({ oldName, newName })
                }
              }

              hasChanges = true
            }
          })
        }
      }
    })

    // Handle .then() chains: import('next/cache').then(({ unstable_cacheTag }) => ...)
    root.find(j.CallExpression).forEach((path) => {
      if (
        path.node.callee?.type === 'MemberExpression' &&
        path.node.callee.property?.type === 'Identifier' &&
        path.node.callee.property.name === 'then' &&
        path.node.callee.object?.type === 'CallExpression' &&
        path.node.callee.object.callee?.type === 'Import' &&
        path.node.callee.object.arguments[0]?.type === 'StringLiteral' &&
        path.node.callee.object.arguments[0].value === 'next/cache' &&
        path.node.arguments.length > 0
      ) {
        const callback = path.node.arguments[0]
        let params = null

        if (callback.type === 'ArrowFunctionExpression') {
          params = callback.params
        } else if (callback.type === 'FunctionExpression') {
          params = callback.params
        }

        if (params && params.length > 0 && params[0].type === 'ObjectPattern') {
          params[0].properties?.forEach((property) => {
            if (
              property.type === 'ObjectProperty' &&
              property.key?.type === 'Identifier' &&
              shouldRenameProperty(property.key.name)
            ) {
              const oldName = property.key.name
              const newName = UNSTABLE_TO_STABLE_MAPPING[oldName]

              property.key = j.identifier(newName)

              if (!property.value) {
                property.value = j.identifier(newName)
                identifierRenames.push({ oldName, newName })
              } else if (property.value.type === 'Identifier') {
                const localName = property.value.name
                if (localName === oldName) {
                  property.value = j.identifier(newName)
                  identifierRenames.push({ oldName, newName })
                } else if (localName === newName) {
                  // Same alias name: { unstable_cacheTag: cacheTag } -> { cacheTag }
                  property.value = j.identifier(newName)
                  property.shorthand = true
                  identifierRenames.push({ oldName, newName })
                }
              }

              hasChanges = true
            }
          })
        }
      }
    })

    // Handle member expressions
    root.find(j.MemberExpression).forEach((path) => {
      const node = path.node

      // Handle direct property access: require('next/cache').unstable_cacheTag
      if (
        node.object?.type === 'CallExpression' &&
        node.object.callee?.type === 'Identifier' &&
        node.object.callee.name === 'require' &&
        node.object.arguments[0]?.type === 'StringLiteral' &&
        node.object.arguments[0].value === 'next/cache'
      ) {
        if (
          node.computed &&
          node.property?.type === 'StringLiteral' &&
          shouldRenameProperty(node.property.value)
        ) {
          const newName = UNSTABLE_TO_STABLE_MAPPING[node.property.value]
          node.property = j.stringLiteral(newName)
          hasChanges = true
        } else if (
          !node.computed &&
          node.property?.type === 'Identifier' &&
          shouldRenameProperty(node.property.name)
        ) {
          const newName = UNSTABLE_TO_STABLE_MAPPING[node.property.name]
          node.property = j.identifier(newName)
          hasChanges = true
        }
      }

      // Handle property access on cache variables: cache.unstable_cacheTag or cache['unstable_cacheTag']
      if (
        node.object?.type === 'Identifier' &&
        cacheVariables.has(node.object.name)
      ) {
        if (
          node.computed &&
          node.property?.type === 'StringLiteral' &&
          shouldRenameProperty(node.property.value)
        ) {
          const newName = UNSTABLE_TO_STABLE_MAPPING[node.property.value]
          node.property = j.stringLiteral(newName)
          hasChanges = true
        } else if (
          !node.computed &&
          node.property?.type === 'Identifier' &&
          shouldRenameProperty(node.property.name)
        ) {
          const newName = UNSTABLE_TO_STABLE_MAPPING[node.property.name]
          node.property = j.identifier(newName)
          hasChanges = true
        }
      }
    })

    // Apply all identifier renames with better scope awareness
    identifierRenames.forEach(({ oldName, newName }) => {
      root
        .find(j.Identifier, { name: oldName })
        .filter((identifierPath) => {
          // Skip renaming declarations themselves
          const parent = identifierPath.parent
          return !(
            parent.node.type === 'ImportSpecifier' ||
            parent.node.type === 'ExportSpecifier' ||
            (parent.node.type === 'ObjectProperty' &&
              parent.node.key === identifierPath.node) ||
            (parent.node.type === 'VariableDeclarator' &&
              parent.node.id === identifierPath.node) ||
            (parent.node.type === 'FunctionDeclaration' &&
              parent.node.id === identifierPath.node) ||
            (parent.node.type === 'Property' &&
              parent.node.key === identifierPath.node &&
              !parent.node.computed)
          )
        })
        .forEach((identifierPath) => {
          identifierPath.node.name = newName
        })
    })

    return hasChanges ? root.toSource(options) : file.source
  } catch (error) {
    console.warn(`Failed to transform ${file.path}: ${error.message}`)
    return file.source
  }
}
Quest for Codev2.0.0
/
SIGN IN