Skip to content

Commit

Permalink
feat: Add scoped-jsx transform (#214)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
eps1lon authored Dec 2, 2023
1 parent 5a6ec2b commit 10fb254
Show file tree
Hide file tree
Showing 7 changed files with 275 additions and 2 deletions.
13 changes: 13 additions & 0 deletions .changeset/purple-cameras-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"types-react-codemod": minor
---

Add scoped-jsx transform

This replaces usage of the deprecated global JSX namespace with usage of the scoped namespace:

```diff
+import { JSX } from 'react'

const element: JSX.Element
```
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# macos only that leads to --immutable-cache errors in CI
.yarn/cache/fsevents-patch-*.zip

# macOS
.DS_STORE
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ Positionals:
"deprecated-react-text", "deprecated-react-type", "deprecated-sfc-element",
"deprecated-sfc", "deprecated-stateless-component",
"deprecated-void-function-component", "experimental-refobject-defaults",
"implicit-children", "preset-18", "preset-19", "useCallback-implicit-any"]
"implicit-children", "preset-18", "preset-19", "scoped-jsx",
"useCallback-implicit-any"]
paths [string] [required]

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

### `preset-18`

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

### `scoped-jsx`

Ensures access to global JSX namespace is now scoped to React (see TODO DT PR link).
This codemod tries to match the existing import style but isn't perfect.
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).

```diff
+import { JSX } from 'react'
-const element: JSX.Element = <div />;
+const element: JSX.Element = <div />;
```

```diff
import * as React from 'react';
-const element: JSX.Element = <div />;
+const element: React.JSX.Element = <div />;
```

## Supported platforms

The following list contains officially supported runtimes.
Expand Down
3 changes: 2 additions & 1 deletion bin/__tests__/types-react-codemod.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ describe("types-react-codemod", () => {
"deprecated-react-text", "deprecated-react-type", "deprecated-sfc-element",
"deprecated-sfc", "deprecated-stateless-component",
"deprecated-void-function-component", "experimental-refobject-defaults",
"implicit-children", "preset-18", "preset-19", "useCallback-implicit-any"]
"implicit-children", "preset-18", "preset-19", "scoped-jsx",
"useCallback-implicit-any"]
paths [string] [required]
Options:
Expand Down
135 changes: 135 additions & 0 deletions transforms/__tests__/scoped-jsx.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
const { describe, expect, test } = require("@jest/globals");
const dedent = require("dedent");
const JscodeshiftTestUtils = require("jscodeshift/dist/testUtils");
const scopedJSXTransform = require("../scoped-jsx");

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

describe("transform scoped-jsx", () => {
test("not modified", () => {
expect(
applyTransform(`
import * as React from 'react';
interface Props {
children?: ReactNode;
}
`),
).toMatchInlineSnapshot(`
"import * as React from 'react';
interface Props {
children?: ReactNode;
}"
`);
});

test("no import yet", () => {
expect(
applyTransform(`
declare const element: JSX.Element;
`),
).toMatchInlineSnapshot(`
"import { JSX } from "react";
declare const element: JSX.Element;"
`);
});

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

test("existing type namespace import", () => {
expect(
applyTransform(`
import type * as React from 'react';
declare const element: JSX.Element;
`),
).toMatchInlineSnapshot(`
"import type * as React from 'react';
declare const element: React.JSX.Element;"
`);
});

test("existing named import", () => {
expect(
applyTransform(`
import { ReactNode } from 'react';
declare const element: JSX.Element;
declare const node: ReactNode;
`),
).toMatchInlineSnapshot(`
"import { ReactNode, JSX } from 'react';
declare const element: JSX.Element;
declare const node: ReactNode;"
`);
});

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

test("existing namespace require", () => {
expect(
applyTransform(`
const React = require('react');
declare const element: JSX.Element;
`),
).toMatchInlineSnapshot(`
"import { JSX } from "react";
const React = require('react');
declare const element: JSX.Element;"
`);
});

test("insert position", () => {
expect(
applyTransform(`
import {} from 'react-dom'
import {} from '@testing-library/react'
declare const element: JSX.Element;
`),
).toMatchInlineSnapshot(`
"import {} from 'react-dom'
import {} from '@testing-library/react'
import { JSX } from "react";
declare const element: JSX.Element;"
`);
});

test("type parameters are preserved", () => {
expect(
applyTransform(`
import * as React from 'react'
declare const attributes: JSX.LibraryManagedAttributes<A, B>;
`),
).toMatchInlineSnapshot(`
"import * as React from 'react'
declare const attributes: React.JSX.LibraryManagedAttributes<A, B>;"
`);
});
});
4 changes: 4 additions & 0 deletions transforms/preset-19.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const deprecatedReactChildTransform = require("./deprecated-react-child");
const deprecatedReactTextTransform = require("./deprecated-react-text");
const deprecatedVoidFunctionComponentTransform = require("./deprecated-void-function-component");
const refobjectDefaultsTransform = require("./experimental-refobject-defaults");
const scopedJsxTransform = require("./scoped-jsx");

/**
* @type {import('jscodeshift').Transform}
Expand All @@ -26,6 +27,9 @@ const transform = (file, api, options) => {
if (transformNames.has("plain-refs")) {
transforms.push(refobjectDefaultsTransform);
}
if (transformNames.has("scoped-jsx")) {
transforms.push(scopedJsxTransform);
}

let wasAlwaysSkipped = true;
const newSource = transforms.reduce((currentFileSource, transform) => {
Expand Down
98 changes: 98 additions & 0 deletions transforms/scoped-jsx.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const parseSync = require("./utils/parseSync");

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

/**
* @type {string | null}
*/
let reactNamespaceName = null;
ast.find(j.ImportDeclaration).forEach((importDeclaration) => {
const node = importDeclaration.value;
if (
node.source.value === "react" &&
node.specifiers?.[0]?.type === "ImportNamespaceSpecifier"
) {
reactNamespaceName = node.specifiers[0].local?.name ?? null;
}
});

const globalNamespaceReferences = ast.find(j.TSTypeReference, (node) => {
const { typeName } = node;

if (typeName.type === "TSQualifiedName") {
return (
typeName.left.type === "Identifier" &&
typeName.left.name === "JSX" &&
typeName.right.type === "Identifier"
);
}
return false;
});

let hasChanges = false;
if (reactNamespaceName !== null && globalNamespaceReferences.length > 0) {
hasChanges = true;

globalNamespaceReferences.replaceWith((typeReference) => {
const namespaceMember = typeReference
.get("typeName")
.get("right")
.get("name").value;

return j.tsTypeReference(
j.tsQualifiedName(
j.tsQualifiedName(
j.identifier(/** @type {string} */ (reactNamespaceName)),
j.identifier("JSX"),
),
j.identifier(namespaceMember),
),
typeReference.value.typeParameters,
);
});
} else if (globalNamespaceReferences.length > 0) {
const reactImport = ast.find(j.ImportDeclaration, {
source: { value: "react" },
});
const jsxImportSpecifier = reactImport.find(j.ImportSpecifier, {
imported: { name: "JSX" },
});

if (jsxImportSpecifier.length === 0) {
hasChanges = true;

const hasExistingReactImport = reactImport.length > 0;
if (hasExistingReactImport) {
reactImport
.get("specifiers")
.value.push(j.importSpecifier(j.identifier("JSX")));
} else {
const jsxNamespaceImport = j.importDeclaration(
[j.importSpecifier(j.identifier("JSX"))],
j.stringLiteral("react"),
);

const lastImport = ast.find(j.ImportDeclaration).at(-1);

if (lastImport.length > 0) {
lastImport.insertAfter(jsxNamespaceImport);
} else {
// TODO: Intuitively I wanted to do `ast.insertBefore` but that crashes
ast.get("program").get("body").value.unshift(jsxNamespaceImport);
}
}
}
}

if (hasChanges) {
return ast.toSource();
}
return file.source;
};

module.exports = deprecatedReactChildTransform;

0 comments on commit 10fb254

Please sign in to comment.