Skip to content

Commit 4191845

Browse files
authored
feat: Add react-element-default-any-props codemod (#371)
1 parent 36ad54e commit 4191845

File tree

7 files changed

+212
-4
lines changed

7 files changed

+212
-4
lines changed

.changeset/bright-trains-end.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"types-react-codemod": minor
3+
---
4+
5+
Add `react-element-default-any-props` codemod
6+
7+
Opt-in codemod in `preset-19`.
8+
9+
```diff
10+
// implies `React.ReactElement<unknown>` in React 19 as opposed to `React.ReactElement<any>` in prior versions.
11+
-declare const element: React.ReactElement
12+
+declare const element: React.ReactElement<any>
13+
```
14+
15+
Only meant to migrate old code not a recommendation for how to type React elements.

README.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ Positionals:
4040
"deprecated-sfc", "deprecated-stateless-component",
4141
"deprecated-void-function-component", "implicit-children",
4242
"no-implicit-ref-callback-return", "preset-18", "preset-19",
43-
"refobject-defaults", "scoped-jsx", "useCallback-implicit-any",
44-
"useRef-required-initial"]
43+
"react-element-default-any-props", "refobject-defaults", "scoped-jsx",
44+
"useCallback-implicit-any", "useRef-required-initial"]
4545
paths [string] [required]
4646

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

374+
### `react-element-default-any-props`
375+
376+
> [!CAUTION]
377+
> This codemod is only meant as a migration helper for old code.
378+
> The new default for props of `React.ReactElement` is `unknown` but a lot of existing code relied on `any`.
379+
> The codemod should only be used if you have a lot of code relying on the old default.
380+
> Typing out the expected shape of the props is recommended.
381+
> It's also likely that manually fixing is sufficient.
382+
> 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.
383+
384+
Off by default in `preset-19`. Can be enabled when running `preset-19`.
385+
386+
Defaults the props of a `React.ReactElement` value to `any` if it has the explicit type.
387+
388+
```diff
389+
-declare const element: React.ReactElement
390+
+declare const element: React.ReactElement<any>
391+
```
392+
393+
Does not overwrite existing type parameters.
394+
395+
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.
396+
397+
The codemod also does not work on type narrowing e.g.
398+
399+
```tsx
400+
if (React.isValidElement(node)) {
401+
element.props.foo;
402+
// ^^^ Cannot access propertiy 'any' of `unknown`
403+
}
404+
```
405+
406+
The props would need to be cast to `any` (e.g. `(element.props as any).foo`) to preserve the old runtime behavior.
407+
374408
### `refobject-defaults`
375409

376410
WARNING: This is an experimental codemod to intended for codebases using unpublished types.

bin/__tests__/types-react-codemod.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ describe("types-react-codemod", () => {
2828
"deprecated-sfc", "deprecated-stateless-component",
2929
"deprecated-void-function-component", "implicit-children",
3030
"no-implicit-ref-callback-return", "preset-18", "preset-19",
31-
"refobject-defaults", "scoped-jsx", "useCallback-implicit-any",
32-
"useRef-required-initial"]
31+
"react-element-default-any-props", "refobject-defaults", "scoped-jsx",
32+
"useCallback-implicit-any", "useRef-required-initial"]
3333
paths [string] [required]
3434
3535
Options:

bin/types-react-codemod.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ async function main() {
106106
{ checked: true, value: "deprecated-react-text" },
107107
{ checked: true, value: "deprecated-void-function-component" },
108108
{ checked: false, value: "no-implicit-ref-callback-return" },
109+
{ checked: false, value: "react-element-default-any-props" },
109110
{ checked: true, value: "refobject-defaults" },
110111
{ checked: true, value: "scoped-jsx" },
111112
{ checked: true, value: "useRef-required-initial" },
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
const { expect, test } = require("@jest/globals");
2+
const dedent = require("dedent");
3+
const JscodeshiftTestUtils = require("jscodeshift/dist/testUtils");
4+
const reactElementDefaultAnyPropsTransform = require("../react-element-default-any-props");
5+
6+
function applyTransform(source, options = {}) {
7+
return JscodeshiftTestUtils.applyTransform(
8+
reactElementDefaultAnyPropsTransform,
9+
options,
10+
{
11+
path: "test.d.ts",
12+
source: dedent(source),
13+
},
14+
);
15+
}
16+
17+
test("not modified", () => {
18+
expect(
19+
applyTransform(`
20+
import * as React from 'react';
21+
declare const element: React.ReactElement<unknown>
22+
`),
23+
).toMatchInlineSnapshot(`
24+
"import * as React from 'react';
25+
declare const element: React.ReactElement<unknown>"
26+
`);
27+
});
28+
29+
test("named import", () => {
30+
expect(
31+
applyTransform(`
32+
import { ReactElement } from 'react';
33+
declare const element: ReactElement
34+
`),
35+
).toMatchInlineSnapshot(`
36+
"import { ReactElement } from 'react';
37+
declare const element: ReactElement<any>"
38+
`);
39+
});
40+
41+
test("named type import", () => {
42+
expect(
43+
applyTransform(`
44+
import { type ReactElement } from 'react';
45+
declare const element: ReactElement
46+
`),
47+
).toMatchInlineSnapshot(`
48+
"import { type ReactElement } from 'react';
49+
declare const element: ReactElement<any>"
50+
`);
51+
});
52+
53+
test("false-negative named renamed import", () => {
54+
expect(
55+
applyTransform(`
56+
import { type ReactElement as MyReactElement } from 'react';
57+
declare const element: MyReactElement
58+
`),
59+
).toMatchInlineSnapshot(`
60+
"import { type ReactElement as MyReactElement } from 'react';
61+
declare const element: MyReactElement"
62+
`);
63+
});
64+
65+
test("namespace import", () => {
66+
expect(
67+
applyTransform(`
68+
import * as React from 'react';
69+
declare const element: React.ReactElement
70+
`),
71+
).toMatchInlineSnapshot(`
72+
"import * as React from 'react';
73+
declare const element: React.ReactElement<any>"
74+
`);
75+
});
76+
77+
test("as type parameter", () => {
78+
expect(
79+
applyTransform(`
80+
import * as React from 'react';
81+
createAction<React.ReactElement>()
82+
createAction<React.ReactElement<unknown>>()
83+
`),
84+
).toMatchInlineSnapshot(`
85+
"import * as React from 'react';
86+
createAction<React.ReactElement<any>>()
87+
createAction<React.ReactElement<unknown>>()"
88+
`);
89+
});

transforms/preset-19.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const deprecatedReactFragmentTransform = require("./deprecated-react-fragment");
66
const deprecatedReactTextTransform = require("./deprecated-react-text");
77
const deprecatedVoidFunctionComponentTransform = require("./deprecated-void-function-component");
88
const noImplicitRefCallbackReturnTransform = require("./no-implicit-ref-callback-return");
9+
const reactElementDefaultAnyPropsTransform = require("./react-element-default-any-props");
910
const refobjectDefaultsTransform = require("./refobject-defaults");
1011
const scopedJsxTransform = require("./scoped-jsx");
1112
const useRefRequiredInitialTransform = require("./useRef-required-initial");
@@ -45,6 +46,9 @@ const transform = (file, api, options) => {
4546
if (transformNames.has("no-implicit-ref-callback-return")) {
4647
transforms.push(noImplicitRefCallbackReturnTransform);
4748
}
49+
if (transformNames.has("react-element-default-any-props")) {
50+
transforms.push(reactElementDefaultAnyPropsTransform);
51+
}
4852
if (transformNames.has("refobject-defaults")) {
4953
transforms.push(refobjectDefaultsTransform);
5054
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
const parseSync = require("./utils/parseSync");
2+
const {
3+
findTSTypeReferenceCollections,
4+
} = require("./utils/jscodeshift-bugfixes");
5+
6+
/**
7+
* @type {import('jscodeshift').Transform}
8+
*/
9+
const reactElementDefaultAnyPropsTransform = (file, api) => {
10+
const j = api.jscodeshift;
11+
const ast = parseSync(file);
12+
13+
let hasChanges = false;
14+
15+
const reactElementTypeReferences = findTSTypeReferenceCollections(
16+
j,
17+
ast,
18+
(typeReference) => {
19+
const { typeName, typeParameters } = typeReference;
20+
if (typeParameters != null) {
21+
return false;
22+
}
23+
24+
if (typeName.type === "TSQualifiedName") {
25+
// `React.ReactElement`
26+
if (
27+
typeName.left.type === "Identifier" &&
28+
typeName.left.name === "React" &&
29+
typeName.right.type === "Identifier" &&
30+
typeName.right.name === "ReactElement"
31+
) {
32+
return true;
33+
}
34+
} else {
35+
// `ReactElement`
36+
if (typeName.name === "ReactElement") {
37+
return true;
38+
}
39+
}
40+
41+
return false;
42+
},
43+
);
44+
45+
for (const typeReferences of reactElementTypeReferences) {
46+
const changedTypes = typeReferences.replaceWith((path) => {
47+
return j.tsTypeReference(
48+
path.get("typeName").value,
49+
j.tsTypeParameterInstantiation([
50+
j.tsTypeReference(j.identifier("any")),
51+
]),
52+
);
53+
});
54+
55+
hasChanges = hasChanges || changedTypes.length > 0;
56+
}
57+
58+
// Otherwise some files will be marked as "modified" because formatting changed
59+
if (hasChanges) {
60+
return ast.toSource();
61+
}
62+
return file.source;
63+
};
64+
65+
module.exports = reactElementDefaultAnyPropsTransform;

0 commit comments

Comments
 (0)