Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down
6 changes: 5 additions & 1 deletion scripts/eslint-plugin-edge/index.mjs
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down
111 changes: 111 additions & 0 deletions scripts/eslint-plugin-edge/react-fc-component-definition.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* ESLint rule to enforce React.FC<Props> for component definitions.
*
* Wrong:
* export const Component = (props: Props): React.ReactElement | null => { ... }
*
* Correct:
* export const Component: React.FC<Props> = 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))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type matching incorrectly flags non-JSX types ending with "Element"

Low Severity

The .endsWith() check combined with 'Element' in JSX_RETURN_TYPES causes false positives. Since getTypeName returns only the rightmost identifier (e.g., 'HTMLElement' for the type HTMLElement), the check 'HTMLElement'.endsWith('Element') evaluates to true. This incorrectly flags PascalCase functions returning HTMLElement, SVGElement, or any custom type ending with Element as needing React.FC<Props>. The second rule correctly uses .includes() for exact matching, but this rule's .endsWith() approach is too broad.

Additional Locations (1)

Fix in Cursor Fix in Web

}

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<Props> 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<Props> instead of an explicit return type. Use: const ${variableName}: React.FC<Props> = props => { ... }`
})
}
}
}
}
134 changes: 134 additions & 0 deletions scripts/eslint-plugin-edge/react-render-function-definition.mjs
Original file line number Diff line number Diff line change
@@ -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<Props>).
*/

// 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 { ... }`
})
}
}
}
}
Loading