Skip to content

Commit 10fb254

Browse files
authored
feat: Add scoped-jsx transform (#214)
* feat: Add `scoped-jsx` transform * Ensure only a single import is added * Ensure type parameters are preserved * rebase * Remove fsevents-patch * Handle existing React import * Add proper changelog
1 parent 5a6ec2b commit 10fb254

File tree

7 files changed

+275
-2
lines changed

7 files changed

+275
-2
lines changed

.changeset/purple-cameras-hunt.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"types-react-codemod": minor
3+
---
4+
5+
Add scoped-jsx transform
6+
7+
This replaces usage of the deprecated global JSX namespace with usage of the scoped namespace:
8+
9+
```diff
10+
+import { JSX } from 'react'
11+
12+
const element: JSX.Element
13+
```

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ dist
127127
.yarn/build-state.yml
128128
.yarn/install-state.gz
129129
.pnp.*
130+
# macos only that leads to --immutable-cache errors in CI
131+
.yarn/cache/fsevents-patch-*.zip
130132

131133
# macOS
132134
.DS_STORE

README.md

+21-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ Positionals:
3838
"deprecated-react-text", "deprecated-react-type", "deprecated-sfc-element",
3939
"deprecated-sfc", "deprecated-stateless-component",
4040
"deprecated-void-function-component", "experimental-refobject-defaults",
41-
"implicit-children", "preset-18", "preset-19", "useCallback-implicit-any"]
41+
"implicit-children", "preset-18", "preset-19", "scoped-jsx",
42+
"useCallback-implicit-any"]
4243
paths [string] [required]
4344

4445
Options:
@@ -71,6 +72,7 @@ The reason being that a false-positive can be reverted easily (assuming you have
7172
- `useCallback-implicit-any`
7273
- `preset-19`
7374
- `deprecated-react-text`
75+
- `scoped-jsx`
7476

7577
### `preset-18`
7678

@@ -287,6 +289,24 @@ import { RefObject as MyRefObject } from "react";
287289
const myRef: MyRefObject<View>;
288290
```
289291

292+
### `scoped-jsx`
293+
294+
Ensures access to global JSX namespace is now scoped to React (see TODO DT PR link).
295+
This codemod tries to match the existing import style but isn't perfect.
296+
If the import style doesn't match your preferences, you should set up auto-fixable lint rules to match this e.g. [`import/order`](https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/order.md).
297+
298+
```diff
299+
+import { JSX } from 'react'
300+
-const element: JSX.Element = <div />;
301+
+const element: JSX.Element = <div />;
302+
```
303+
304+
```diff
305+
import * as React from 'react';
306+
-const element: JSX.Element = <div />;
307+
+const element: React.JSX.Element = <div />;
308+
```
309+
290310
## Supported platforms
291311

292312
The following list contains officially supported runtimes.

bin/__tests__/types-react-codemod.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ describe("types-react-codemod", () => {
2525
"deprecated-react-text", "deprecated-react-type", "deprecated-sfc-element",
2626
"deprecated-sfc", "deprecated-stateless-component",
2727
"deprecated-void-function-component", "experimental-refobject-defaults",
28-
"implicit-children", "preset-18", "preset-19", "useCallback-implicit-any"]
28+
"implicit-children", "preset-18", "preset-19", "scoped-jsx",
29+
"useCallback-implicit-any"]
2930
paths [string] [required]
3031
3132
Options:

transforms/__tests__/scoped-jsx.js

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
const { describe, expect, test } = require("@jest/globals");
2+
const dedent = require("dedent");
3+
const JscodeshiftTestUtils = require("jscodeshift/dist/testUtils");
4+
const scopedJSXTransform = require("../scoped-jsx");
5+
6+
function applyTransform(source, options = {}) {
7+
return JscodeshiftTestUtils.applyTransform(scopedJSXTransform, options, {
8+
path: "test.d.ts",
9+
source: dedent(source),
10+
});
11+
}
12+
13+
describe("transform scoped-jsx", () => {
14+
test("not modified", () => {
15+
expect(
16+
applyTransform(`
17+
import * as React from 'react';
18+
interface Props {
19+
children?: ReactNode;
20+
}
21+
`),
22+
).toMatchInlineSnapshot(`
23+
"import * as React from 'react';
24+
interface Props {
25+
children?: ReactNode;
26+
}"
27+
`);
28+
});
29+
30+
test("no import yet", () => {
31+
expect(
32+
applyTransform(`
33+
declare const element: JSX.Element;
34+
`),
35+
).toMatchInlineSnapshot(`
36+
"import { JSX } from "react";
37+
declare const element: JSX.Element;"
38+
`);
39+
});
40+
41+
test("existing namespace import", () => {
42+
expect(
43+
applyTransform(`
44+
import * as React from 'react';
45+
declare const element: JSX.Element;
46+
`),
47+
).toMatchInlineSnapshot(`
48+
"import * as React from 'react';
49+
declare const element: React.JSX.Element;"
50+
`);
51+
});
52+
53+
test("existing type namespace import", () => {
54+
expect(
55+
applyTransform(`
56+
import type * as React from 'react';
57+
declare const element: JSX.Element;
58+
`),
59+
).toMatchInlineSnapshot(`
60+
"import type * as React from 'react';
61+
declare const element: React.JSX.Element;"
62+
`);
63+
});
64+
65+
test("existing named import", () => {
66+
expect(
67+
applyTransform(`
68+
import { ReactNode } from 'react';
69+
declare const element: JSX.Element;
70+
declare const node: ReactNode;
71+
`),
72+
).toMatchInlineSnapshot(`
73+
"import { ReactNode, JSX } from 'react';
74+
declare const element: JSX.Element;
75+
declare const node: ReactNode;"
76+
`);
77+
});
78+
79+
test("existing named import", () => {
80+
expect(
81+
applyTransform(`
82+
import { JSX } from 'react';
83+
declare const element: JSX.Element;
84+
`),
85+
).toMatchInlineSnapshot(`
86+
"import { JSX } from 'react';
87+
declare const element: JSX.Element;"
88+
`);
89+
});
90+
91+
test("existing namespace require", () => {
92+
expect(
93+
applyTransform(`
94+
const React = require('react');
95+
declare const element: JSX.Element;
96+
`),
97+
).toMatchInlineSnapshot(`
98+
"import { JSX } from "react";
99+
const React = require('react');
100+
declare const element: JSX.Element;"
101+
`);
102+
});
103+
104+
test("insert position", () => {
105+
expect(
106+
applyTransform(`
107+
import {} from 'react-dom'
108+
import {} from '@testing-library/react'
109+
110+
declare const element: JSX.Element;
111+
`),
112+
).toMatchInlineSnapshot(`
113+
"import {} from 'react-dom'
114+
import {} from '@testing-library/react'
115+
116+
import { JSX } from "react";
117+
118+
declare const element: JSX.Element;"
119+
`);
120+
});
121+
122+
test("type parameters are preserved", () => {
123+
expect(
124+
applyTransform(`
125+
import * as React from 'react'
126+
127+
declare const attributes: JSX.LibraryManagedAttributes<A, B>;
128+
`),
129+
).toMatchInlineSnapshot(`
130+
"import * as React from 'react'
131+
132+
declare const attributes: React.JSX.LibraryManagedAttributes<A, B>;"
133+
`);
134+
});
135+
});

transforms/preset-19.js

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const deprecatedReactChildTransform = require("./deprecated-react-child");
22
const deprecatedReactTextTransform = require("./deprecated-react-text");
33
const deprecatedVoidFunctionComponentTransform = require("./deprecated-void-function-component");
44
const refobjectDefaultsTransform = require("./experimental-refobject-defaults");
5+
const scopedJsxTransform = require("./scoped-jsx");
56

67
/**
78
* @type {import('jscodeshift').Transform}
@@ -26,6 +27,9 @@ const transform = (file, api, options) => {
2627
if (transformNames.has("plain-refs")) {
2728
transforms.push(refobjectDefaultsTransform);
2829
}
30+
if (transformNames.has("scoped-jsx")) {
31+
transforms.push(scopedJsxTransform);
32+
}
2933

3034
let wasAlwaysSkipped = true;
3135
const newSource = transforms.reduce((currentFileSource, transform) => {

transforms/scoped-jsx.js

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
const parseSync = require("./utils/parseSync");
2+
3+
/**
4+
* @type {import('jscodeshift').Transform}
5+
*/
6+
const deprecatedReactChildTransform = (file, api) => {
7+
const j = api.jscodeshift;
8+
const ast = parseSync(file);
9+
10+
/**
11+
* @type {string | null}
12+
*/
13+
let reactNamespaceName = null;
14+
ast.find(j.ImportDeclaration).forEach((importDeclaration) => {
15+
const node = importDeclaration.value;
16+
if (
17+
node.source.value === "react" &&
18+
node.specifiers?.[0]?.type === "ImportNamespaceSpecifier"
19+
) {
20+
reactNamespaceName = node.specifiers[0].local?.name ?? null;
21+
}
22+
});
23+
24+
const globalNamespaceReferences = ast.find(j.TSTypeReference, (node) => {
25+
const { typeName } = node;
26+
27+
if (typeName.type === "TSQualifiedName") {
28+
return (
29+
typeName.left.type === "Identifier" &&
30+
typeName.left.name === "JSX" &&
31+
typeName.right.type === "Identifier"
32+
);
33+
}
34+
return false;
35+
});
36+
37+
let hasChanges = false;
38+
if (reactNamespaceName !== null && globalNamespaceReferences.length > 0) {
39+
hasChanges = true;
40+
41+
globalNamespaceReferences.replaceWith((typeReference) => {
42+
const namespaceMember = typeReference
43+
.get("typeName")
44+
.get("right")
45+
.get("name").value;
46+
47+
return j.tsTypeReference(
48+
j.tsQualifiedName(
49+
j.tsQualifiedName(
50+
j.identifier(/** @type {string} */ (reactNamespaceName)),
51+
j.identifier("JSX"),
52+
),
53+
j.identifier(namespaceMember),
54+
),
55+
typeReference.value.typeParameters,
56+
);
57+
});
58+
} else if (globalNamespaceReferences.length > 0) {
59+
const reactImport = ast.find(j.ImportDeclaration, {
60+
source: { value: "react" },
61+
});
62+
const jsxImportSpecifier = reactImport.find(j.ImportSpecifier, {
63+
imported: { name: "JSX" },
64+
});
65+
66+
if (jsxImportSpecifier.length === 0) {
67+
hasChanges = true;
68+
69+
const hasExistingReactImport = reactImport.length > 0;
70+
if (hasExistingReactImport) {
71+
reactImport
72+
.get("specifiers")
73+
.value.push(j.importSpecifier(j.identifier("JSX")));
74+
} else {
75+
const jsxNamespaceImport = j.importDeclaration(
76+
[j.importSpecifier(j.identifier("JSX"))],
77+
j.stringLiteral("react"),
78+
);
79+
80+
const lastImport = ast.find(j.ImportDeclaration).at(-1);
81+
82+
if (lastImport.length > 0) {
83+
lastImport.insertAfter(jsxNamespaceImport);
84+
} else {
85+
// TODO: Intuitively I wanted to do `ast.insertBefore` but that crashes
86+
ast.get("program").get("body").value.unshift(jsxNamespaceImport);
87+
}
88+
}
89+
}
90+
}
91+
92+
if (hasChanges) {
93+
return ast.toSource();
94+
}
95+
return file.source;
96+
};
97+
98+
module.exports = deprecatedReactChildTransform;

0 commit comments

Comments
 (0)