From 10fb254afbccab5e7e1941bfee49f957ca7ed1a5 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Sat, 2 Dec 2023 22:35:42 +0100 Subject: [PATCH] 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 --- .changeset/purple-cameras-hunt.md | 13 +++ .gitignore | 2 + README.md | 22 ++++- bin/__tests__/types-react-codemod.js | 3 +- transforms/__tests__/scoped-jsx.js | 135 +++++++++++++++++++++++++++ transforms/preset-19.js | 4 + transforms/scoped-jsx.js | 98 +++++++++++++++++++ 7 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 .changeset/purple-cameras-hunt.md create mode 100644 transforms/__tests__/scoped-jsx.js create mode 100644 transforms/scoped-jsx.js diff --git a/.changeset/purple-cameras-hunt.md b/.changeset/purple-cameras-hunt.md new file mode 100644 index 00000000..186a93e8 --- /dev/null +++ b/.changeset/purple-cameras-hunt.md @@ -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 +``` diff --git a/.gitignore b/.gitignore index af983c76..5e1cf326 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 8f572e68..2228cef4 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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` @@ -287,6 +289,24 @@ import { RefObject as MyRefObject } from "react"; const myRef: MyRefObject; ``` +### `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 =
; ++const element: JSX.Element =
; +``` + +```diff + import * as React from 'react'; +-const element: JSX.Element =
; ++const element: React.JSX.Element =
; +``` + ## Supported platforms The following list contains officially supported runtimes. diff --git a/bin/__tests__/types-react-codemod.js b/bin/__tests__/types-react-codemod.js index ba5a234e..7d5be253 100644 --- a/bin/__tests__/types-react-codemod.js +++ b/bin/__tests__/types-react-codemod.js @@ -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: diff --git a/transforms/__tests__/scoped-jsx.js b/transforms/__tests__/scoped-jsx.js new file mode 100644 index 00000000..af47c2a9 --- /dev/null +++ b/transforms/__tests__/scoped-jsx.js @@ -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; + `), + ).toMatchInlineSnapshot(` + "import * as React from 'react' + + declare const attributes: React.JSX.LibraryManagedAttributes;" + `); + }); +}); diff --git a/transforms/preset-19.js b/transforms/preset-19.js index 357e77cf..8d9ca4c3 100644 --- a/transforms/preset-19.js +++ b/transforms/preset-19.js @@ -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} @@ -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) => { diff --git a/transforms/scoped-jsx.js b/transforms/scoped-jsx.js new file mode 100644 index 00000000..816fffbd --- /dev/null +++ b/transforms/scoped-jsx.js @@ -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;