diff --git a/eslint.config.mjs b/eslint.config.mjs index ed0f869c3fc..8513678f77d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -40,6 +40,8 @@ export default [ 'react-native/sort-styles': 'off', // Add our own rules: + 'edge/react-fc-component-definition': 'warn', + 'edge/react-render-function-definition': 'warn', 'edge/useAbortable-abort-check-param': 'error', 'edge/useAbortable-abort-check-usage': 'error', diff --git a/scripts/eslint-plugin-edge/index.mjs b/scripts/eslint-plugin-edge/index.mjs index 8b50c5ccaad..006e9bba051 100644 --- a/scripts/eslint-plugin-edge/index.mjs +++ b/scripts/eslint-plugin-edge/index.mjs @@ -1,13 +1,17 @@ +import reactFcComponentDefinition from './react-fc-component-definition.mjs' +import reactRenderFunctionDefinition from './react-render-function-definition.mjs' import abortCheckParam from './useAbortable-abort-check-param.mjs' import abortCheckUsage from './useAbortable-abort-check-usage.mjs' export default { meta: { name: 'eslint-plugin-edge', - version: '0.1.2', + version: '0.1.3', namespace: 'edge' }, rules: { + 'react-fc-component-definition': reactFcComponentDefinition, + 'react-render-function-definition': reactRenderFunctionDefinition, 'useAbortable-abort-check-param': abortCheckParam, 'useAbortable-abort-check-usage': abortCheckUsage } diff --git a/scripts/eslint-plugin-edge/react-fc-component-definition.mjs b/scripts/eslint-plugin-edge/react-fc-component-definition.mjs new file mode 100644 index 00000000000..ef409cc6ccd --- /dev/null +++ b/scripts/eslint-plugin-edge/react-fc-component-definition.mjs @@ -0,0 +1,111 @@ +/** + * ESLint rule to enforce React.FC for component definitions. + * + * Wrong: + * export const Component = (props: Props): React.ReactElement | null => { ... } + * + * Correct: + * export const Component: React.FC = props => { ... } + * + * This rule only targets PascalCase-named arrow functions to distinguish + * components from render helper functions (which should use explicit return types). + */ + +const JSX_RETURN_TYPES = [ + 'ReactElement', + 'ReactNode', + 'Element', // JSX.Element + 'JSXElement' +] + +function isPascalCase(name) { + return /^[A-Z][a-zA-Z0-9]*$/.test(name) +} + +function isJsxReturnType(typeAnnotation) { + if (!typeAnnotation) return false + + const { typeAnnotation: innerType } = typeAnnotation + + // Handle union types like `React.ReactElement | null` + if (innerType.type === 'TSUnionType') { + return innerType.types.some(t => isJsxType(t)) + } + + return isJsxType(innerType) +} + +function isJsxType(node) { + if (!node) return false + + // Handle `React.ReactElement`, `React.JSX.Element`, etc. + if (node.type === 'TSTypeReference') { + const typeName = getTypeName(node.typeName) + return JSX_RETURN_TYPES.some(t => typeName.endsWith(t)) + } + + return false +} + +function getTypeName(typeName) { + if (typeName.type === 'Identifier') { + return typeName.name + } + // Handle qualified names like `React.ReactElement` or `React.JSX.Element` + // We only need the rightmost name since we use `.endsWith()` checks + if (typeName.type === 'TSQualifiedName') { + return typeName.right.name + } + return '' +} + +export default { + meta: { + type: 'suggestion', + docs: { + description: + 'Enforce React.FC for component definitions instead of explicit return types', + category: 'Stylistic Issues', + recommended: false + }, + schema: [] + }, + create(context) { + return { + VariableDeclarator(node) { + // Check if this is an arrow function + if (node.init?.type !== 'ArrowFunctionExpression') return + + // Check if the variable name is PascalCase (component convention) + const variableName = node.id?.name + if (!variableName || !isPascalCase(variableName)) return + + // Check if the arrow function has an explicit return type + const arrowFunction = node.init + if (!arrowFunction.returnType) return + + // Check if the return type is a JSX-related type + if (!isJsxReturnType(arrowFunction.returnType)) return + + // Check if the variable already has a React.FC type annotation + const variableType = node.id.typeAnnotation + if (variableType) { + const typeName = getTypeName( + variableType.typeAnnotation?.typeName || {} + ) + if ( + typeName.endsWith('FC') || + typeName.endsWith('FunctionComponent') + ) { + return // Already using React.FC + } + } + + context.report({ + node: arrowFunction.returnType, + message: `Component '${variableName}' should use React.FC instead of an explicit return type. Use: const ${variableName}: React.FC = props => { ... }` + }) + } + } + } +} diff --git a/scripts/eslint-plugin-edge/react-render-function-definition.mjs b/scripts/eslint-plugin-edge/react-render-function-definition.mjs new file mode 100644 index 00000000000..05188963a6e --- /dev/null +++ b/scripts/eslint-plugin-edge/react-render-function-definition.mjs @@ -0,0 +1,134 @@ +/** + * ESLint rule to enforce React.ReactElement for render helper functions. + * + * Wrong: + * const renderItem = (item: Item): React.ReactNode => { ... } + * const renderHeader = (): JSX.Element => { ... } + * + * Correct: + * const renderItem = (item: Item): React.ReactElement => { ... } + * const renderBadge = (): React.ReactElement | null => { ... } + * + * This rule targets camelCase functions starting with "render" to distinguish + * render helpers from components (which should use React.FC). + */ + +// Types that should be flagged and replaced with ReactElement +const DISALLOWED_TYPES = [ + 'ReactNode', // Too broad - use ReactElement or explicit union + 'Element' // JSX.Element - use ReactElement for consistency +] + +function isRenderFunction(name) { + return /^render[A-Z]/.test(name) +} + +function getTypeName(typeName) { + if (typeName.type === 'Identifier') { + return typeName.name + } + // Handle qualified names like `React.ReactNode` or `JSX.Element` + // We only need the rightmost name since we use exact matches + if (typeName.type === 'TSQualifiedName') { + return typeName.right.name + } + return '' +} + +function isDisallowedType(node) { + if (!node) return null + + // Handle `React.ReactNode`, `JSX.Element`, etc. + if (node.type === 'TSTypeReference') { + const typeName = getTypeName(node.typeName) + if (DISALLOWED_TYPES.includes(typeName)) { + return typeName + } + } + + return null +} + +function checkReturnType(typeAnnotation) { + if (!typeAnnotation) return null + + const { typeAnnotation: innerType } = typeAnnotation + + // Handle union types like `React.ReactElement | null` + // Only flag if the union contains a disallowed type + if (innerType.type === 'TSUnionType') { + for (const t of innerType.types) { + const disallowed = isDisallowedType(t) + if (disallowed != null) { + return disallowed + } + } + return null + } + + return isDisallowedType(innerType) +} + +export default { + meta: { + type: 'suggestion', + docs: { + description: + 'Enforce React.ReactElement for render helper functions instead of ReactNode or JSX.Element', + category: 'Stylistic Issues', + recommended: false + }, + schema: [] + }, + create(context) { + return { + VariableDeclarator(node) { + // Check if this is an arrow function + if (node.init?.type !== 'ArrowFunctionExpression') return + + // Check if the variable name starts with "render" (camelCase render helper) + const variableName = node.id?.name + if (!variableName || !isRenderFunction(variableName)) return + + // Check if the arrow function has an explicit return type + const arrowFunction = node.init + if (!arrowFunction.returnType) return + + // Check if the return type is a disallowed type + const disallowedType = checkReturnType(arrowFunction.returnType) + if (disallowedType == null) return + + const suggestion = + disallowedType === 'ReactNode' + ? 'React.ReactElement (or React.ReactElement | null for nullable returns)' + : 'React.ReactElement' + + context.report({ + node: arrowFunction.returnType, + message: `Render function '${variableName}' should return ${suggestion} instead of ${disallowedType}. Use: const ${variableName} = (...): React.ReactElement => { ... }` + }) + }, + + // Also check regular function declarations + FunctionDeclaration(node) { + const functionName = node.id?.name + if (!functionName || !isRenderFunction(functionName)) return + + if (!node.returnType) return + + const disallowedType = checkReturnType(node.returnType) + if (disallowedType == null) return + + const suggestion = + disallowedType === 'ReactNode' + ? 'React.ReactElement (or React.ReactElement | null for nullable returns)' + : 'React.ReactElement' + + context.report({ + node: node.returnType, + message: `Render function '${functionName}' should return ${suggestion} instead of ${disallowedType}. Use: function ${functionName}(...): React.ReactElement { ... }` + }) + } + } + } +}