From 3879a65d536d1a5726acc104fa1b1ae8f57375ae Mon Sep 17 00:00:00 2001 From: Adam Cooper Date: Tue, 7 Jul 2026 12:34:39 -0400 Subject: [PATCH] Fix remove-forward-ref TypeScript output for React 19 (#336) The transform had three problems with the ref prop type it emitted: 1. The ref prop was typed as `ref: React.RefObject` -- required and RefObject-only. A forwardRef component accepts an optional ref that may be a ref object, a callback ref, or null, so every call site that didn't pass a ref (or passed a callback ref) became a type error. Emit `ref?: React.Ref` instead, matching the type that ForwardRefExoticComponent exposed. 2. Props and ref types were only carried over when they were a simple type reference or type literal. Union types, intersections, and other type expressions (e.g. `forwardRef>`) caused the annotation to be dropped entirely, leaving an untyped destructure where every prop was inferred as any. Accept any TS type, parenthesizing unions and conditional types when placed in the emitted intersection. 3. The ref argument's annotation was only recognized in the qualified `React.ForwardedRef` form. Recognize bare named imports (`ForwardedRef`, `Ref`, etc.) and non-reference element types such as unions. Fixes #336 Co-Authored-By: Claude Fable 5 --- .../typescript/props-type-literal.output.js | 2 +- .../typescript/ref-arg-named-import.input.js | 9 +++ .../typescript/ref-arg-named-import.output.js | 12 ++++ .../typescript/ref-arg-union-type.input.js | 8 +++ .../typescript/ref-arg-union-type.output.js | 10 +++ .../type-arguments-custom-names.output.js | 2 +- ...type-arguments-intersection-props.input.js | 5 ++ ...ype-arguments-intersection-props.output.js | 10 +++ .../type-arguments-type-literals.output.js | 2 +- ...-arguments-union-of-intersections.input.js | 5 ++ ...arguments-union-of-intersections.output.js | 10 +++ .../type-arguments-union-props.input.js | 5 ++ .../type-arguments-union-props.output.js | 10 +++ .../type-arguments-union-ref.input.js | 5 ++ .../type-arguments-union-ref.output.js | 10 +++ .../typescript/type-arguments.output.js | 2 +- .../__tests__/remove-forward-ref.test.ts | 14 ++-- transforms/remove-forward-ref.ts | 65 ++++++++++++------- 18 files changed, 153 insertions(+), 33 deletions(-) create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-named-import.input.js create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-named-import.output.js create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-union-type.input.js create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-union-type.output.js create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-intersection-props.input.js create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-intersection-props.output.js create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-of-intersections.input.js create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-of-intersections.output.js create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-props.input.js create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-props.output.js create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-ref.input.js create mode 100644 transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-ref.output.js diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/props-type-literal.output.js b/transforms/__testfixtures__/remove-forward-ref/typescript/props-type-literal.output.js index 56fa6f5..b7bd3f6 100644 --- a/transforms/__testfixtures__/remove-forward-ref/typescript/props-type-literal.output.js +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/props-type-literal.output.js @@ -3,7 +3,7 @@ const MyComponent = function Component( ref: myRef, ...myProps }: { a: 1 } & { - ref: React.RefObject + ref?: React.Ref } ) { return null; diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-named-import.input.js b/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-named-import.input.js new file mode 100644 index 0000000..673e06e --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-named-import.input.js @@ -0,0 +1,9 @@ +import { forwardRef } from 'react'; +import type { ForwardedRef } from 'react'; + +const MyComponent = forwardRef(function Component( + myProps: Props, + myRef: ForwardedRef +) { + return null; +}); diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-named-import.output.js b/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-named-import.output.js new file mode 100644 index 0000000..dadd064 --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-named-import.output.js @@ -0,0 +1,12 @@ +import type { ForwardedRef } from 'react'; + +const MyComponent = function Component( + { + ref: myRef, + ...myProps + }: Props & { + ref?: React.Ref + } +) { + return null; +}; diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-union-type.input.js b/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-union-type.input.js new file mode 100644 index 0000000..908e004 --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-union-type.input.js @@ -0,0 +1,8 @@ +import { forwardRef } from 'react'; + +const MyTab = forwardRef(function Tab( + props: TabProps | LinkTabProps, + ref: React.ForwardedRef +) { + return null; +}); diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-union-type.output.js b/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-union-type.output.js new file mode 100644 index 0000000..5997dfe --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/ref-arg-union-type.output.js @@ -0,0 +1,10 @@ +const MyTab = function Tab( + { + ref, + ...props + }: (TabProps | LinkTabProps) & { + ref?: React.Ref + } +) { + return null; +}; diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-custom-names.output.js b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-custom-names.output.js index e868776..28941be 100644 --- a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-custom-names.output.js +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-custom-names.output.js @@ -3,7 +3,7 @@ const MyComponent = function Component( ref: myRef, ...myProps }: Props & { - ref: React.RefObject + ref?: React.Ref } ) { return null; diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-intersection-props.input.js b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-intersection-props.input.js new file mode 100644 index 0000000..3f11de9 --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-intersection-props.input.js @@ -0,0 +1,5 @@ +import { forwardRef } from 'react'; + +const MyTab = forwardRef & ExtraProps>((props, ref) => { + return null; +}); diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-intersection-props.output.js b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-intersection-props.output.js new file mode 100644 index 0000000..c567737 --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-intersection-props.output.js @@ -0,0 +1,10 @@ +const MyTab = ( + { + ref, + ...props + }: PropsWithChildren & ExtraProps & { + ref?: React.Ref + } +) => { + return null; +}; diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-type-literals.output.js b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-type-literals.output.js index f569dfc..35ee10b 100644 --- a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-type-literals.output.js +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-type-literals.output.js @@ -3,7 +3,7 @@ const MyInput = ( ref, ...props }: { a: string } & { - ref: React.RefObject + ref?: React.Ref } ) => { return null; diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-of-intersections.input.js b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-of-intersections.input.js new file mode 100644 index 0000000..7713f35 --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-of-intersections.input.js @@ -0,0 +1,5 @@ +import { forwardRef } from 'react'; + +const MyElement = forwardRef((props, ref) => { + return null; +}); diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-of-intersections.output.js b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-of-intersections.output.js new file mode 100644 index 0000000..d6d8b86 --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-of-intersections.output.js @@ -0,0 +1,10 @@ +const MyElement = ( + { + ref, + ...props + }: ((BaseProps & MetaProps) | (ButtonProps & LinkProps)) & { + ref?: React.Ref + } +) => { + return null; +}; diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-props.input.js b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-props.input.js new file mode 100644 index 0000000..d7dcd8c --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-props.input.js @@ -0,0 +1,5 @@ +import { forwardRef } from 'react'; + +const MyInput = forwardRef((props, ref) => { + return null; +}); diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-props.output.js b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-props.output.js new file mode 100644 index 0000000..d4b4bc7 --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-props.output.js @@ -0,0 +1,10 @@ +const MyInput = ( + { + ref, + ...props + }: (TextProps | NumberProps) & { + ref?: React.Ref + } +) => { + return null; +}; diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-ref.input.js b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-ref.input.js new file mode 100644 index 0000000..51b35bb --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-ref.input.js @@ -0,0 +1,5 @@ +import { forwardRef } from 'react'; + +const MyButton = forwardRef((props, ref) => { + return null; +}); diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-ref.output.js b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-ref.output.js new file mode 100644 index 0000000..cc3c556 --- /dev/null +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments-union-ref.output.js @@ -0,0 +1,10 @@ +const MyButton = ( + { + ref, + ...props + }: Props & { + ref?: React.Ref + } +) => { + return null; +}; diff --git a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments.output.js b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments.output.js index 23e1c16..c0f172c 100644 --- a/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments.output.js +++ b/transforms/__testfixtures__/remove-forward-ref/typescript/type-arguments.output.js @@ -5,7 +5,7 @@ const MyInput = ( ref, ...props }: Props & { - ref: React.RefObject + ref?: React.Ref } ) => { return null; diff --git a/transforms/__tests__/remove-forward-ref.test.ts b/transforms/__tests__/remove-forward-ref.test.ts index 9f76f62..1c0cc89 100644 --- a/transforms/__tests__/remove-forward-ref.test.ts +++ b/transforms/__tests__/remove-forward-ref.test.ts @@ -12,10 +12,16 @@ const jsTests = [ ]; const tsTests = [ - 'type-arguments', - 'type-arguments-custom-names', - 'type-arguments-type-literals', - 'props-type-literal' + 'type-arguments', + 'type-arguments-custom-names', + 'type-arguments-type-literals', + 'type-arguments-union-ref', + 'type-arguments-union-props', + 'type-arguments-union-of-intersections', + 'type-arguments-intersection-props', + 'props-type-literal', + 'ref-arg-named-import', + 'ref-arg-union-type' ]; const defineTest = require('jscodeshift/dist/testUtils').defineTest; diff --git a/transforms/remove-forward-ref.ts b/transforms/remove-forward-ref.ts index da7ad9d..a7ed4a9 100644 --- a/transforms/remove-forward-ref.ts +++ b/transforms/remove-forward-ref.ts @@ -6,27 +6,34 @@ import type { FunctionExpression, Identifier, JSCodeshift, - TSTypeLiteral, - TSTypeReference, + TSType, } from 'jscodeshift'; -// Props & { ref: React.RefObject} +// union and conditional types bind looser than an intersection, so they +// need to be parenthesized when used as an intersection member +const parenthesizeForIntersection = (j: JSCodeshift, type: TSType) => + j.TSUnionType.check(type) || j.TSConditionalType.check(type) + ? j.tsParenthesizedType(type) + : type; + +// Props & { ref?: React.Ref } const buildPropsAndRefIntersectionTypeAnnotation = ( j: JSCodeshift, - propType: TSTypeReference | TSTypeLiteral, - refType: TSTypeReference | TSTypeLiteral | null, + propType: TSType, + refType: TSType | null, ) => j.tsTypeAnnotation( j.tsIntersectionType([ - propType, + parenthesizeForIntersection(j, propType), j.tsTypeLiteral([ j.tsPropertySignature.from({ key: j.identifier('ref'), + optional: true, typeAnnotation: j.tsTypeAnnotation( j.tsTypeReference.from({ typeName: j.tsQualifiedName( j.identifier('React'), - j.identifier('RefObject'), + j.identifier('Ref'), ), typeParameters: j.tsTypeParameterInstantiation([ refType === null ? j.tsUnknownKeyword() : refType, @@ -53,25 +60,39 @@ const buildRefAndPropsObjectPattern = ( j.restProperty(j.identifier(propArgName)), ]); +const REF_WRAPPER_TYPE_NAMES = [ + 'ForwardedRef', + 'Ref', + 'LegacyRef', + 'MutableRefObject', + 'RefObject', +]; + // React.ForwardedRef => HTMLButtonElement +// ForwardedRef => HTMLButtonElement const getRefTypeFromRefArg = (j: JSCodeshift, refArg: Identifier) => { const typeReference = refArg.typeAnnotation?.typeAnnotation; - if ( - !j.TSTypeReference.check(typeReference) || - !j.TSQualifiedName.check(typeReference.typeName) - ) { + if (!j.TSTypeReference.check(typeReference)) { return null; } - const { right } = typeReference.typeName; + const { typeName } = typeReference; - if (!j.Identifier.check(right) || right.name === 'forwardedRef') { + const wrapperName = j.TSQualifiedName.check(typeName) + ? j.Identifier.check(typeName.right) + ? typeName.right.name + : null + : j.Identifier.check(typeName) + ? typeName.name + : null; + + if (wrapperName === null || !REF_WRAPPER_TYPE_NAMES.includes(wrapperName)) { return null; } const [firstTypeParameter] = typeReference.typeParameters?.params ?? []; - if (!j.TSTypeReference.check(firstTypeParameter)) { + if (!j.TSType.check(firstTypeParameter)) { return null; } @@ -94,11 +115,8 @@ const getForwardRefRenderFunction = ( return renderFunction; }; -const isLiteralOrReference = ( - j: JSCodeshift, - type: unknown, -): type is TSTypeReference | TSTypeLiteral => { - return j.TSTypeReference.check(type) || j.TSTypeLiteral.check(type); +const isTSType = (j: JSCodeshift, type: unknown): type is TSType => { + return j.TSType.check(type); }; export default function transform(file: FileInfo, api: API) { @@ -220,7 +238,7 @@ export default function transform(file: FileInfo, api: API) { */ if ( - isLiteralOrReference(j, propsArgTypeReference) && + isTSType(j, propsArgTypeReference) && renderFunction.params?.[0] && 'typeAnnotation' in renderFunction.params[0] ) { @@ -240,7 +258,7 @@ export default function transform(file: FileInfo, api: API) { const typeParameters = callExpressionPath.node.typeParameters; // if typeParameters are used in forwardRef generic, reuse them to annotate props type - // forwardRef((props) => { ... }) ====> (props: Props & { ref: React.RefObject }) => { ... } + // forwardRef((props) => { ... }) ====> (props: Props & { ref?: React.Ref }) => { ... } if ( j.TSTypeParameterInstantiation.check(typeParameters) && renderFunction.params?.[0] && @@ -248,10 +266,7 @@ export default function transform(file: FileInfo, api: API) { ) { const [refType, propType] = typeParameters.params; - if ( - j.TSTypeReference.check(refType) && - isLiteralOrReference(j, propType) - ) { + if (isTSType(j, refType) && isTSType(j, propType)) { renderFunction.params[0].typeAnnotation = buildPropsAndRefIntersectionTypeAnnotation(j, propType, refType);