From 0047404d18665a2b18c0859e013d643022fc23e5 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Tue, 5 Dec 2023 20:59:35 +0100 Subject: [PATCH] Add codemod for required initial value in `useRef` (#217) * Add codemod for required initial value in `useRef` * Rebase * Format --- .changeset/fifty-cheetahs-dance.md | 8 +++ README.md | 31 ++++++++- bin/__tests__/types-react-codemod.js | 4 +- transforms/__tests__/preset-19.js | 12 ++++ .../__tests__/useRef-required-initial.js | 65 +++++++++++++++++++ .../experimental-useRef-required-initial.js | 42 ++++++++++++ transforms/preset-19.js | 6 +- 7 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 .changeset/fifty-cheetahs-dance.md create mode 100644 transforms/__tests__/useRef-required-initial.js create mode 100644 transforms/experimental-useRef-required-initial.js diff --git a/.changeset/fifty-cheetahs-dance.md b/.changeset/fifty-cheetahs-dance.md new file mode 100644 index 00000000..2a97aa97 --- /dev/null +++ b/.changeset/fifty-cheetahs-dance.md @@ -0,0 +1,8 @@ +--- +"types-react-codemod": minor +--- + +Add codemod for required initial value in `useRef` + +Added as `experimental-useRef-required-initial`. +Can be used on 18.x types but only intended for once https://github.com/DefinitelyTyped/DefinitelyTyped/pull/64920 lands. diff --git a/README.md b/README.md index 2228cef4..e39aa9ac 100644 --- a/README.md +++ b/README.md @@ -38,8 +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", "scoped-jsx", - "useCallback-implicit-any"] + "experimental-useRef-required-initial", "implicit-children", "preset-18", + "preset-19", "scoped-jsx", "useCallback-implicit-any"] paths [string] [required] Options: @@ -267,7 +267,7 @@ In earlier versions of `@types/react` this codemod would change the typings. ### `experimental-refobject-defaults` -WARNING: This is an experimental codemod to intended for codebases using published types. +WARNING: This is an experimental codemod to intended for codebases using unpublished types. Only use if you're using https://github.com/DefinitelyTyped/DefinitelyTyped/pull/64896. `RefObject` no longer makes `current` nullable by default @@ -307,6 +307,31 @@ If the import style doesn't match your preferences, you should set up auto-fixab +const element: React.JSX.Element =
; ``` +### `experimental-useRef-required-initial` + +WARNING: This is an experimental codemod to intended for codebases using unpublished types. +Only use if you're using https://github.com/DefinitelyTyped/DefinitelyTyped/pull/64920. + +`useRef` now always requires an initial value. +Implicit `undefined` is forbidden + +```diff + import * as React from "react"; +-React.useRef() ++React.useRef(undefined) +``` + +#### `experimental-useRef-required-initial` false-negative pattern A + +Importing `useRef` via aliased named import will result in the transform being skipped. + +```tsx +import { useRef as useReactRef } from "react"; + +// not transformed +useReactRef(); +``` + ## 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 7d5be253..8f6bf3b0 100644 --- a/bin/__tests__/types-react-codemod.js +++ b/bin/__tests__/types-react-codemod.js @@ -25,8 +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", "scoped-jsx", - "useCallback-implicit-any"] + "experimental-useRef-required-initial", "implicit-children", "preset-18", + "preset-19", "scoped-jsx", "useCallback-implicit-any"] paths [string] [required] Options: diff --git a/transforms/__tests__/preset-19.js b/transforms/__tests__/preset-19.js index 2a8e9beb..fade5670 100644 --- a/transforms/__tests__/preset-19.js +++ b/transforms/__tests__/preset-19.js @@ -7,6 +7,8 @@ describe("preset-19", () => { let deprecatedReactChildTransform; let deprecatedReactTextTransform; let deprecatedVoidFunctionComponentTransform; + let refobjectDefaultsTransform; + let useRefRequiredInitialTransform; function applyTransform(source, options = {}) { return JscodeshiftTestUtils.applyTransform(preset19Transform, options, { @@ -33,6 +35,12 @@ describe("preset-19", () => { deprecatedVoidFunctionComponentTransform = mockTransform( "../deprecated-void-function-component", ); + refobjectDefaultsTransform = mockTransform( + "../experimental-refobject-defaults", + ); + useRefRequiredInitialTransform = mockTransform( + "../experimental-useRef-required-initial", + ); preset19Transform = require("../preset-19"); }); @@ -53,11 +61,15 @@ describe("preset-19", () => { "deprecated-react-child", "deprecated-react-text", "deprecated-void-function-component", + "experimental-refobject-defaults", + "experimental-useRef-required-initial", ].join(","), }); expect(deprecatedReactChildTransform).toHaveBeenCalled(); expect(deprecatedReactTextTransform).toHaveBeenCalled(); expect(deprecatedVoidFunctionComponentTransform).toHaveBeenCalled(); + expect(refobjectDefaultsTransform).toHaveBeenCalled(); + expect(useRefRequiredInitialTransform).toHaveBeenCalled(); }); }); diff --git a/transforms/__tests__/useRef-required-initial.js b/transforms/__tests__/useRef-required-initial.js new file mode 100644 index 00000000..538fe941 --- /dev/null +++ b/transforms/__tests__/useRef-required-initial.js @@ -0,0 +1,65 @@ +const { describe, expect, test } = require("@jest/globals"); +const dedent = require("dedent"); +const JscodeshiftTestUtils = require("jscodeshift/dist/testUtils"); +const useRefRequiredInitial = require("../experimental-useRef-required-initial"); + +function applyTransform(source, options = {}) { + return JscodeshiftTestUtils.applyTransform(useRefRequiredInitial, options, { + path: "test.tsx", + source: dedent(source), + }); +} + +describe("transform useRef-required-initial", () => { + 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("named import", () => { + expect( + applyTransform(` + import { useRef } from 'react'; + const myRef = useRef(); + `), + ).toMatchInlineSnapshot(` + "import { useRef } from 'react'; + const myRef = useRef(undefined);" + `); + }); + + test("false-negative named renamed import", () => { + expect( + applyTransform(` + import { useRef as useReactRef } from 'react'; + const myRef = useReactRef(); + `), + ).toMatchInlineSnapshot(` + "import { useRef as useReactRef } from 'react'; + const myRef = useReactRef();" + `); + }); + + test("namespace import", () => { + expect( + applyTransform(` + import * as React from 'react'; + const myRef = React.useRef(); + `), + ).toMatchInlineSnapshot(` + "import * as React from 'react'; + const myRef = React.useRef(undefined);" + `); + }); +}); diff --git a/transforms/experimental-useRef-required-initial.js b/transforms/experimental-useRef-required-initial.js new file mode 100644 index 00000000..8fb8876f --- /dev/null +++ b/transforms/experimental-useRef-required-initial.js @@ -0,0 +1,42 @@ +const parseSync = require("./utils/parseSync"); +const t = require("@babel/types"); +const traverse = require("@babel/traverse").default; + +/** + * @type {import('jscodeshift').Transform} + * + * Summary for Klarna's klapp@? + * TODO + */ +const useRefRequiredInitialTransform = (file) => { + const ast = parseSync(file); + + let changedSome = false; + + // ast.get("program").value is sufficient for unit tests but not actually running it on files + // TODO: How to test? + const traverseRoot = ast.paths()[0].value; + traverse(traverseRoot, { + CallExpression({ node: callExpression }) { + const isUseRefCall = + (callExpression.callee.type === "Identifier" && + callExpression.callee.name === "useRef") || + (callExpression.callee.type === "MemberExpression" && + callExpression.callee.property.type === "Identifier" && + callExpression.callee.property.name === "useRef"); + + if (isUseRefCall && callExpression.arguments.length === 0) { + changedSome = true; + callExpression.arguments = [t.identifier("undefined")]; + } + }, + }); + + // Otherwise some files will be marked as "modified" because formatting changed + if (changedSome) { + return ast.toSource(); + } + return file.source; +}; + +module.exports = useRefRequiredInitialTransform; diff --git a/transforms/preset-19.js b/transforms/preset-19.js index 8d9ca4c3..9ca79295 100644 --- a/transforms/preset-19.js +++ b/transforms/preset-19.js @@ -3,6 +3,7 @@ const deprecatedReactTextTransform = require("./deprecated-react-text"); const deprecatedVoidFunctionComponentTransform = require("./deprecated-void-function-component"); const refobjectDefaultsTransform = require("./experimental-refobject-defaults"); const scopedJsxTransform = require("./scoped-jsx"); +const useRefRequiredInitialTransform = require("./experimental-useRef-required-initial"); /** * @type {import('jscodeshift').Transform} @@ -24,12 +25,15 @@ const transform = (file, api, options) => { if (transformNames.has("deprecated-void-function-component")) { transforms.push(deprecatedVoidFunctionComponentTransform); } - if (transformNames.has("plain-refs")) { + if (transformNames.has("experimental-refobject-defaults")) { transforms.push(refobjectDefaultsTransform); } if (transformNames.has("scoped-jsx")) { transforms.push(scopedJsxTransform); } + if (transformNames.has("experimental-useRef-required-initial")) { + transforms.push(useRefRequiredInitialTransform); + } let wasAlwaysSkipped = true; const newSource = transforms.reduce((currentFileSource, transform) => {