Files
Desktop/codemods/react-jsx/04-fix-type-references.mjs
2026-04-30 11:41:57 -07:00

184 lines
6.0 KiB
JavaScript

// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { declare } from '@babel/helper-plugin-utils';
/**
* @import { types as t, PluginObj, PluginPass } from '@babel/core'
*/
/**
* Update references to `React.*` in types that were missed by 02-to-named-exports.mjs
*
* ```diff
* - import { useMemo } from 'react';
* + import { useMemo, type JSX } from 'react';
*
* - type X = React.JSX.Element
* + type X = JSX.Element
* ```
*/
export default declare(function transform({ types: t }) {
/**
* @typedef {PluginPass & {
* existingSpecifiers?: Set<string>,
* reactImportNode?: t.ImportDeclaration | null,
* }} State
*/
/** @type {PluginObj<State>} */
return {
// Setup state for the transform, we don't want to re-import
// imports/specifiers that we already import, so we use this state to track
// what has been added and which import declaration we need to add to.
pre() {
/** @type {Set<string>} */
this.existingSpecifiers = new Set();
this.reactImportNode = null;
},
visitor: {
// Visit all import declarations first to collect all of the existing
// import specifiers before we start adding new ones
ImportDeclaration(path) {
// Ignore non-react imports
if (path.node.source.value !== 'react') {
return;
}
// Make typescript happy
if (this.existingSpecifiers == null) {
throw new Error('Missing state');
}
const existingSpecifiers = this.existingSpecifiers;
// Iterate through each specifier and add it to `existingSpecifiers`
for (const specifier of path.node.specifiers) {
// Ignore default and namespace imports
if (specifier.type !== 'ImportSpecifier') {
continue;
}
// Ignore string literal imports, we dont have any
if (specifier.imported.type !== 'Identifier') {
throw path.buildCodeFrameError('Expected identifier');
}
const importedName = specifier.imported.name;
const localName = specifier.local.name;
// Assert we're not doing a rename to keep this codemod simpler
if (importedName !== localName) {
throw path.buildCodeFrameError(
`Expected "${localName}" === "${importedName}"`
);
}
// Save the specifier so we don't re-add it
existingSpecifiers.add(importedName);
}
// Store which import node we want to add to
if (
// Prefer earlier imports
this.reactImportNode == null ||
// Prefer type import
(this.reactImportNode.importKind !== 'type' &&
path.node.importKind === 'type')
) {
this.reactImportNode = path.node;
}
},
// Check identifier for `React.*` type references.
Identifier(path, pluginPass) {
// We're looking for a "React" global, but skip the check to see what
// it is referencing since we messed up the earlier codemod. Manually
// grepped for any references to React that werent a know export of
// react
if (path.node.name !== 'React') {
return;
}
// Make TS happy
if (this.existingSpecifiers == null) {
throw new Error('Missing state');
}
const existingSpecifiers = this.existingSpecifiers;
// Ignore if we're not a type reference. "TSQualfiedName" is basically
// a member expression in typescript.
const qualifiedName = path.parentPath;
if (!qualifiedName.isTSQualifiedName()) {
return;
}
// Get the member name we need to import
const memberName = qualifiedName.node.right.name;
// Replace `React.*` with `*`
qualifiedName.replaceWith(t.identifier(memberName));
// Already have import specifier
if (existingSpecifiers.has(memberName)) {
return;
}
// Make sure we don't re-add it again in the same file
existingSpecifiers.add(memberName);
const specifier = t.importSpecifier(
t.identifier(memberName),
t.identifier(memberName)
);
// Already have an import declaration we can just push to
if (this.reactImportNode != null) {
// Only mark the specifier as a 'type' if the import declaration is not already
if (this.reactImportNode.importKind !== 'type') {
specifier.importKind = 'type';
}
this.reactImportNode.specifiers.push(specifier);
return;
}
// Otherwise create a new import declaration
const importDecl = t.importDeclaration(
[specifier],
t.stringLiteral('react')
);
// Since its new we can just make the entire thing a 'type' import
importDecl.importKind = 'type';
// Look up the first statement in the program so we can copy comments
// off of it
const programPath = pluginPass.file.path;
const [firstStatement] = programPath.get('body');
if (firstStatement) {
// Babel uses `node.leadingComments/trailingComments/innerComments`
// but recast uses `node.comments`, both exist on the ast nodes, but
// since recast is doing the printing we need to use `node.comments`
//
// ...I wish I knew this in earlier codemods...
// @ts-expect-error
importDecl.comments = firstStatement.node.comments;
// @ts-expect-error
firstStatement.node.comments = null;
// Insert the declaration before the first statement
firstStatement.insertBefore(importDecl);
} else {
// This will never happen, but if the program has no statements then
// this handles that, I don't know why I bothered
programPath.unshiftContainer('body', importDecl);
}
this.reactImportNode = importDecl;
},
},
};
});