diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..916b71d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# test fixtures +/__testfixtures__ \ No newline at end of file diff --git a/README.md b/README.md index 1291c99..f8cd91f 100644 --- a/README.md +++ b/README.md @@ -9,42 +9,20 @@ npx @jowapp/codemod [...options] ``` - `transform` - name of transform, see available transforms below. -- `path` - files or directory to transform (use quotes otherwise the path(s) will be expanded by your shell, [which may not support globstar](https://medium.com/@jakubsynowiec/you-should-always-quote-your-globs-in-npm-scripts-621887a2a784)) +- `path` - files or directory to transform (**use quotes**) - use the `--dry` option for a dry-run and use `--print` to print the output for comparison This will start an interactive wizard, and then run the specified transform. -All jscodeshift's options are supported, and defaults are the same than the one of jscodeshift's CLI. +All [jscodeshift's options](https://github.com/facebook/jscodeshift?tab=readme-ov-file#usage-cli) are supported, and defaults are the same than the one of jscodeshift's CLI. > [!NOTE] -> jscodeshift's option defaults apply only to its CLI, its API has none, except the parser which defaults to `@babel/parser` (see `babel5Compat.js`) - +> jscodeshift's option defaults apply only to its CLI, its API has none, except the parser, which defaults to `@babel/parser` (see `babel5Compat.js`) > All options are also passed to the transformer, which means you can supply custom options that are not listed here. -### Recast Options - -Options to [recast](https://github.com/benjamn/recast)'s printer can also be provided through jscodeshift's `printOptions` command line argument, as `react-codemod` does it: - -```sh -npx react-codemod --jscodeshift="--printOptions='{\"quote\":\"double\"}'" -``` - -### Unit Testing - -Unit tests are based on [jscodeshift's test utility](https://github.com/facebook/jscodeshift?tab=readme-ov-file#unit-testing) with `vitest`. - -But here the directory structure is a bit different: - -``` -/subfolder/MyTransform.js -/__tests__/subfolder/MyTransform-test.js -/__testfixtures__/subfolder/MyTransform.input.js -/__testfixtures__/subfolder/MyTransform.output.js -``` - ## Included Transforms -### React +### Migrate to React 19 #### `react.func-default-props-to-params` @@ -52,7 +30,7 @@ But here the directory structure is a bit different: npx @jowapp/codemod react.func-default-props-to-params ``` -Converts functional components default props to default params. +Converts functional components default props onto default parameters. #### `react.forward-ref-to-prop` @@ -60,4 +38,12 @@ Converts functional components default props to default params. npx @jowapp/codemod react.forward-ref-to-prop ``` -Converts `forwardRef` to regular prop `ref`. +Converts `forwardRef` to regular `ref` prop. + +## Testing + +```sh +yarn test:run +## or +yarn test:watch +``` diff --git a/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-display-name.input.js b/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-display-name.input.js new file mode 100644 index 0000000..69e2f7e --- /dev/null +++ b/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-display-name.input.js @@ -0,0 +1,12 @@ +import { forwardRef } from 'react'; + +const C1 = forwardRef((props, ref) => { + return ; +}); + +const C2 = forwardRef((props, ref) => { + return ; +}); + +C1.displayName = 'C1'; +C2.displayName = 'C2'; diff --git a/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-display-name.output.js b/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-display-name.output.js new file mode 100644 index 0000000..4e6abd5 --- /dev/null +++ b/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-display-name.output.js @@ -0,0 +1,17 @@ +const C1 = ( + { + ref, + ...props + } +) => { + return ; +}; + +const C2 = ( + { + ref, + ...props + } +) => { + return ; +}; diff --git a/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-props-object-pattern.input.js b/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-props-object-pattern.input.js new file mode 100644 index 0000000..2fdca14 --- /dev/null +++ b/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-props-object-pattern.input.js @@ -0,0 +1,5 @@ +import { forwardRef } from 'react'; + +const C = forwardRef(function MyInput({ onChange }, ref) { + return ; +}); diff --git a/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-props-object-pattern.output.js b/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-props-object-pattern.output.js new file mode 100644 index 0000000..8b68e3c --- /dev/null +++ b/__testfixtures__/react/forward-ref-to-prop/arrow-function-expression-w-props-object-pattern.output.js @@ -0,0 +1,8 @@ +const C = function MyInput( + { + ref, + onChange + } +) { + return ; +}; diff --git a/__testfixtures__/react/forward-ref-to-prop/function-expression-w-multiple-imports.input.js b/__testfixtures__/react/forward-ref-to-prop/function-expression-w-multiple-imports.input.js new file mode 100644 index 0000000..d435ca5 --- /dev/null +++ b/__testfixtures__/react/forward-ref-to-prop/function-expression-w-multiple-imports.input.js @@ -0,0 +1,5 @@ +import { forwardRef, useState } from 'react'; + +const C = forwardRef(function MyInput(props, ref) { + return ; +}); diff --git a/__testfixtures__/react/forward-ref-to-prop/function-expression-w-multiple-imports.output.js b/__testfixtures__/react/forward-ref-to-prop/function-expression-w-multiple-imports.output.js new file mode 100644 index 0000000..48aedc3 --- /dev/null +++ b/__testfixtures__/react/forward-ref-to-prop/function-expression-w-multiple-imports.output.js @@ -0,0 +1,10 @@ +import { useState } from 'react'; + +const C = function MyInput( + { + ref, + ...props + } +) { + return ; +}; diff --git a/__testfixtures__/react/forward-ref-to-prop/function-expression.input.js b/__testfixtures__/react/forward-ref-to-prop/function-expression.input.js new file mode 100644 index 0000000..60209a5 --- /dev/null +++ b/__testfixtures__/react/forward-ref-to-prop/function-expression.input.js @@ -0,0 +1,5 @@ +import { forwardRef } from 'react'; + +const C = forwardRef(function MyInput(props, ref) { + return ; +}); diff --git a/__testfixtures__/react/forward-ref-to-prop/function-expression.output.js b/__testfixtures__/react/forward-ref-to-prop/function-expression.output.js new file mode 100644 index 0000000..827433f --- /dev/null +++ b/__testfixtures__/react/forward-ref-to-prop/function-expression.output.js @@ -0,0 +1,8 @@ +const C = function MyInput( + { + ref, + ...props + } +) { + return ; +}; diff --git a/__testfixtures__/react/forward-ref-to-prop/no-forward-ref.input.js b/__testfixtures__/react/forward-ref-to-prop/no-forward-ref.input.js new file mode 100644 index 0000000..4747b67 --- /dev/null +++ b/__testfixtures__/react/forward-ref-to-prop/no-forward-ref.input.js @@ -0,0 +1,7 @@ +import { useState } from 'react'; + +const C = (props, ref) => { + return ; +}; + +export default C; \ No newline at end of file diff --git a/__tests__/react/forward-ref-to-prop.test.js b/__tests__/react/forward-ref-to-prop.test.js new file mode 100644 index 0000000..3cda5ce --- /dev/null +++ b/__tests__/react/forward-ref-to-prop.test.js @@ -0,0 +1,48 @@ +'use strict' + +import path from 'node:path' +import fs from 'fs' +import { defineTest } from 'jscodeshift/dist/testUtils' +import transform from '../../transforms/react/forward-ref-to-prop' + +const jsTests = [ + 'function-expression', + 'function-expression-w-multiple-imports', + 'arrow-function-expression-w-display-name', + 'arrow-function-expression-w-props-object-pattern', +] + +const jsTestsNoOutput = ['no-forward-ref'] + +describe('react.forward-ref-to-prop', () => { + jsTests.forEach((test) => + defineTest( + path.join(__dirname, '..'), + 'transforms/react/forward-ref-to-prop', + null, + `react/forward-ref-to-prop/${test}`, + ), + ) + + describe('transforms/react/forward-ref-to-prop', () => { + it.each(jsTestsNoOutput)('does not transform using "react/forward-ref-to-prop/%s"', (test) => { + const applyTransform = require('jscodeshift/dist/testUtils').applyTransform + const transformOptions = {} + const fixtureDir = path.join( + __dirname, + '..', + '..', + '__testfixtures__', + 'react', + 'forward-ref-to-prop', + ) + const inputPath = path.join(fixtureDir, test + '.input.js') + const inputSource = fs.readFileSync(inputPath, 'utf8') + const output = applyTransform(transform, transformOptions, { + source: inputSource, + path: inputPath, + }) + expect(output).toBe('') + }) + }) +}) diff --git a/transforms/react/forward-ref-to-prop.js b/transforms/react/forward-ref-to-prop.js index e69de29..f2ff131 100644 --- a/transforms/react/forward-ref-to-prop.js +++ b/transforms/react/forward-ref-to-prop.js @@ -0,0 +1,125 @@ +module.exports = (fileInfo, api, options) => { + // console.log(fileInfo.path) + const j = api.jscodeshift + const printOptions = options.printOptions || {} + const root = j(fileInfo.source) + + let isDirty = false + + // { ref: refName, ...propsName } + const buildRefAndPropsObjectPattern = (j, refArgName, propArgName) => + j.objectPattern([ + j.objectProperty.from({ + shorthand: true, + key: j.identifier('ref'), + value: j.identifier(refArgName), + }), + j.restProperty(j.identifier(propArgName)), + ]) + + const getForwardRefRenderFunction = (j, callExpression) => { + const [renderFunction] = callExpression.arguments + if ( + !j.FunctionExpression.check(renderFunction) && + !j.ArrowFunctionExpression.check(renderFunction) + ) { + return null + } + return renderFunction + } + + let componentsNames = [] + + root + .find(j.CallExpression, { + callee: { + type: 'Identifier', + name: 'forwardRef', + }, + }) + .replaceWith((callExpressionPath) => { + const originalCallExpression = callExpressionPath.value + + const renderFunction = getForwardRefRenderFunction(j, callExpressionPath.node) + + if (renderFunction === null) { + console.warn('Could not detect render function.') + + return originalCallExpression + } + + const [propsArg, refArg] = renderFunction.params + + if ( + !j.Identifier.check(refArg) || + !(j.Identifier.check(propsArg) || j.ObjectPattern.check(propsArg)) + ) { + console.warn('Could not detect ref or props arguments.') + + return originalCallExpression + } + + // remove refArg + renderFunction.params.splice(1, 1) + isDirty = true + + componentsNames.push(callExpressionPath.parent.node.id.name) + + // if propsArg is ObjectPattern, add ref as new ObjectProperty + if (j.ObjectPattern.check(propsArg)) { + propsArg.properties.unshift( + j.objectProperty.from({ + shorthand: true, + key: j.identifier('ref'), + value: j.identifier(refArg.name), + }), + ) + } + + // if props arg is Identifier, push ref variable declaration to the function body + if (j.Identifier.check(propsArg)) { + renderFunction.params[0] = buildRefAndPropsObjectPattern(j, refArg.name, propsArg.name) + } + + return renderFunction + }) + + if (isDirty) { + // handle import + root + .find(j.ImportDeclaration, { + source: { + value: 'react', + }, + }) + .forEach((importDeclarationPath) => { + const { specifiers } = importDeclarationPath.node + + const specifiersWithoutForwardRef = + specifiers?.filter( + (s) => j.ImportSpecifier.check(s) && s.imported.name !== 'forwardRef', + ) ?? [] + + if (specifiersWithoutForwardRef.length === 0) { + j(importDeclarationPath).remove() + } + + importDeclarationPath.node.specifiers = specifiersWithoutForwardRef + }) + // handle displayName + componentsNames.forEach((componentName) => { + root + .find(j.AssignmentExpression, { + left: { + object: { name: componentName }, + property: { name: 'displayName' }, + }, + }) + .remove() + }) + } + + return isDirty ? root.toSource(printOptions) : null +} + +// module.exports.parser = 'babel'