Skip to content

Commit

Permalink
feat: Add react-element-default-any-props codemod (#371)
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon committed Mar 21, 2024
1 parent 36ad54e commit 4191845
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 4 deletions.
15 changes: 15 additions & 0 deletions .changeset/bright-trains-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"types-react-codemod": minor
---

Add `react-element-default-any-props` codemod

Opt-in codemod in `preset-19`.

```diff
// implies `React.ReactElement<unknown>` in React 19 as opposed to `React.ReactElement<any>` in prior versions.
-declare const element: React.ReactElement
+declare const element: React.ReactElement<any>
```

Only meant to migrate old code not a recommendation for how to type React elements.
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ Positionals:
"deprecated-sfc", "deprecated-stateless-component",
"deprecated-void-function-component", "implicit-children",
"no-implicit-ref-callback-return", "preset-18", "preset-19",
"refobject-defaults", "scoped-jsx", "useCallback-implicit-any",
"useRef-required-initial"]
"react-element-default-any-props", "refobject-defaults", "scoped-jsx",
"useCallback-implicit-any", "useRef-required-initial"]
paths [string] [required]

Options:
Expand Down Expand Up @@ -371,6 +371,40 @@ With ref cleanups, this is no longer the case and flagged in types to avoid mist
This only works for the `ref` prop.
The codemod will not apply to other props that take refs (e.g. `innerRef`).

### `react-element-default-any-props`

> [!CAUTION]
> This codemod is only meant as a migration helper for old code.
> The new default for props of `React.ReactElement` is `unknown` but a lot of existing code relied on `any`.
> The codemod should only be used if you have a lot of code relying on the old default.
> Typing out the expected shape of the props is recommended.
> It's also likely that manually fixing is sufficient.
> In [vercel/nextjs we only had to fix one file](https://github.com/eps1lon/next.js/pull/1/commits/97fcba326ef465d134862feb1990f875d360675e) while the codemod would've changed 15 files.
Off by default in `preset-19`. Can be enabled when running `preset-19`.

Defaults the props of a `React.ReactElement` value to `any` if it has the explicit type.

```diff
-declare const element: React.ReactElement
+declare const element: React.ReactElement<any>
```

Does not overwrite existing type parameters.

The codemod does not work when the a value has the `React.ReactElement` type from 3rd party dependencies e.g. in `const: element: React.ReactNode`, the element would still have `unknown` props.

The codemod also does not work on type narrowing e.g.

```tsx
if (React.isValidElement(node)) {
element.props.foo;
// ^^^ Cannot access propertiy 'any' of `unknown`
}
```

The props would need to be cast to `any` (e.g. `(element.props as any).foo`) to preserve the old runtime behavior.

### `refobject-defaults`

WARNING: This is an experimental codemod to intended for codebases using unpublished types.
Expand Down
4 changes: 2 additions & 2 deletions bin/__tests__/types-react-codemod.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ describe("types-react-codemod", () => {
"deprecated-sfc", "deprecated-stateless-component",
"deprecated-void-function-component", "implicit-children",
"no-implicit-ref-callback-return", "preset-18", "preset-19",
"refobject-defaults", "scoped-jsx", "useCallback-implicit-any",
"useRef-required-initial"]
"react-element-default-any-props", "refobject-defaults", "scoped-jsx",
"useCallback-implicit-any", "useRef-required-initial"]
paths [string] [required]
Options:
Expand Down
1 change: 1 addition & 0 deletions bin/types-react-codemod.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ async function main() {
{ checked: true, value: "deprecated-react-text" },
{ checked: true, value: "deprecated-void-function-component" },
{ checked: false, value: "no-implicit-ref-callback-return" },
{ checked: false, value: "react-element-default-any-props" },
{ checked: true, value: "refobject-defaults" },
{ checked: true, value: "scoped-jsx" },
{ checked: true, value: "useRef-required-initial" },
Expand Down
89 changes: 89 additions & 0 deletions transforms/__tests__/react-element-default-any-props.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const { expect, test } = require("@jest/globals");
const dedent = require("dedent");
const JscodeshiftTestUtils = require("jscodeshift/dist/testUtils");
const reactElementDefaultAnyPropsTransform = require("../react-element-default-any-props");

function applyTransform(source, options = {}) {
return JscodeshiftTestUtils.applyTransform(
reactElementDefaultAnyPropsTransform,
options,
{
path: "test.d.ts",
source: dedent(source),
},
);
}

test("not modified", () => {
expect(
applyTransform(`
import * as React from 'react';
declare const element: React.ReactElement<unknown>
`),
).toMatchInlineSnapshot(`
"import * as React from 'react';
declare const element: React.ReactElement<unknown>"
`);
});

test("named import", () => {
expect(
applyTransform(`
import { ReactElement } from 'react';
declare const element: ReactElement
`),
).toMatchInlineSnapshot(`
"import { ReactElement } from 'react';
declare const element: ReactElement<any>"
`);
});

test("named type import", () => {
expect(
applyTransform(`
import { type ReactElement } from 'react';
declare const element: ReactElement
`),
).toMatchInlineSnapshot(`
"import { type ReactElement } from 'react';
declare const element: ReactElement<any>"
`);
});

test("false-negative named renamed import", () => {
expect(
applyTransform(`
import { type ReactElement as MyReactElement } from 'react';
declare const element: MyReactElement
`),
).toMatchInlineSnapshot(`
"import { type ReactElement as MyReactElement } from 'react';
declare const element: MyReactElement"
`);
});

test("namespace import", () => {
expect(
applyTransform(`
import * as React from 'react';
declare const element: React.ReactElement
`),
).toMatchInlineSnapshot(`
"import * as React from 'react';
declare const element: React.ReactElement<any>"
`);
});

test("as type parameter", () => {
expect(
applyTransform(`
import * as React from 'react';
createAction<React.ReactElement>()
createAction<React.ReactElement<unknown>>()
`),
).toMatchInlineSnapshot(`
"import * as React from 'react';
createAction<React.ReactElement<any>>()
createAction<React.ReactElement<unknown>>()"
`);
});
4 changes: 4 additions & 0 deletions transforms/preset-19.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const deprecatedReactFragmentTransform = require("./deprecated-react-fragment");
const deprecatedReactTextTransform = require("./deprecated-react-text");
const deprecatedVoidFunctionComponentTransform = require("./deprecated-void-function-component");
const noImplicitRefCallbackReturnTransform = require("./no-implicit-ref-callback-return");
const reactElementDefaultAnyPropsTransform = require("./react-element-default-any-props");
const refobjectDefaultsTransform = require("./refobject-defaults");
const scopedJsxTransform = require("./scoped-jsx");
const useRefRequiredInitialTransform = require("./useRef-required-initial");
Expand Down Expand Up @@ -45,6 +46,9 @@ const transform = (file, api, options) => {
if (transformNames.has("no-implicit-ref-callback-return")) {
transforms.push(noImplicitRefCallbackReturnTransform);
}
if (transformNames.has("react-element-default-any-props")) {
transforms.push(reactElementDefaultAnyPropsTransform);
}
if (transformNames.has("refobject-defaults")) {
transforms.push(refobjectDefaultsTransform);
}
Expand Down
65 changes: 65 additions & 0 deletions transforms/react-element-default-any-props.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const parseSync = require("./utils/parseSync");
const {
findTSTypeReferenceCollections,
} = require("./utils/jscodeshift-bugfixes");

/**
* @type {import('jscodeshift').Transform}
*/
const reactElementDefaultAnyPropsTransform = (file, api) => {
const j = api.jscodeshift;
const ast = parseSync(file);

let hasChanges = false;

const reactElementTypeReferences = findTSTypeReferenceCollections(
j,
ast,
(typeReference) => {
const { typeName, typeParameters } = typeReference;
if (typeParameters != null) {
return false;
}

if (typeName.type === "TSQualifiedName") {
// `React.ReactElement`
if (
typeName.left.type === "Identifier" &&
typeName.left.name === "React" &&
typeName.right.type === "Identifier" &&
typeName.right.name === "ReactElement"
) {
return true;
}
} else {
// `ReactElement`
if (typeName.name === "ReactElement") {
return true;
}
}

return false;
},
);

for (const typeReferences of reactElementTypeReferences) {
const changedTypes = typeReferences.replaceWith((path) => {
return j.tsTypeReference(
path.get("typeName").value,
j.tsTypeParameterInstantiation([
j.tsTypeReference(j.identifier("any")),
]),
);
});

hasChanges = hasChanges || changedTypes.length > 0;
}

// Otherwise some files will be marked as "modified" because formatting changed
if (hasChanges) {
return ast.toSource();
}
return file.source;
};

module.exports = reactElementDefaultAnyPropsTransform;

0 comments on commit 4191845

Please sign in to comment.