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
Original file line number Diff line number Diff line change
Expand Up @@ -1650,6 +1650,58 @@ const allTests = {
useEffectEventError('onClick', true),
],
},
{
code: normalizeIndent`
// Invalid because rules-of-hooks must also apply to components wrapped in wrapper functions.
// This is a case where it wraps a properly named function.
// This *must* be invalid.
const ComponentWithConditionalHook = anyWrapper(function ComponentWithConditionalHook() {
if (cond) {
useConditionalHook();
}
})
`,
errors: [conditionalError('useConditionalHook')],
},
{
code: normalizeIndent`
// Invalid because rules-of-hooks must also apply to components wrapped in wrapper functions.
// This is a case where it wraps an anonymous function.
// This *must* be invalid.
const ComponentWithConditionalHook = anyWrapper(function() {
if (cond) {
useConditionalHook();
}
})
`,
errors: [conditionalError('useConditionalHook')],
},
{
code: normalizeIndent`
// Invalid because rules-of-hooks must also apply to components wrapped in wrapper functions.
// This is a case where it wraps an arrow function.
// This *must* be invalid.
const ComponentWithConditionalHook = anyWrapper(() => {
if (cond) {
useConditionalHook();
}
})
`,
errors: [conditionalError('useConditionalHook')],
},
{
code: normalizeIndent`
// Invalid because rules-of-hooks must also apply to components wrapped in many wrapper functions.
// This is a case where it double wraps an arrow function.
// This *must* be invalid.
const ComponentWithConditionalHook = anyWrapper1(anyWrapper2(() => {
if (cond) {
useConditionalHook();
}
}))
`,
errors: [conditionalError('useConditionalHook')],
},
],
};

Expand Down
114 changes: 82 additions & 32 deletions packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,32 +69,6 @@ function isReactFunction(node: Node, functionName: string): boolean {
);
}

/**
* Checks if the node is a callback argument of forwardRef. This render function
* should follow the rules of hooks.
*/
function isForwardRefCallback(node: Node): boolean {
return !!(
node.parent &&
'callee' in node.parent &&
node.parent.callee &&
isReactFunction(node.parent.callee, 'forwardRef')
);
}

/**
* Checks if the node is a callback argument of React.memo. This anonymous
* functional component should follow the rules of hooks.
*/
function isMemoCallback(node: Node): boolean {
return !!(
node.parent &&
'callee' in node.parent &&
node.parent.callee &&
isReactFunction(node.parent.callee, 'memo')
);
}

function isInsideComponentOrHook(node: Node | undefined): boolean {
while (node) {
const functionName = getFunctionName(node);
Expand All @@ -103,8 +77,12 @@ function isInsideComponentOrHook(node: Node | undefined): boolean {
return true;
}
}
if (isForwardRefCallback(node) || isMemoCallback(node)) {
return true;
const functionNameSkippingCallExpressions =
getFunctionNameSkippingCallExpressions(node);
if (functionNameSkippingCallExpressions) {
if (isComponentName(functionNameSkippingCallExpressions)) {
return true;
}
}
node = node.parent;
}
Expand Down Expand Up @@ -514,10 +492,27 @@ const rule = {
// function component or we are in a hook function.
const isSomewhereInsideComponentOrHook =
isInsideComponentOrHook(codePathNode);
const isDirectlyInsideComponentOrHook = codePathFunctionName
? isComponentName(codePathFunctionName) ||
isHook(codePathFunctionName)
: isForwardRefCallback(codePathNode) || isMemoCallback(codePathNode);

const isDirectlyInsideComponentOrHook = (() => {
if (
codePathFunctionName &&
(isComponentName(codePathFunctionName) ||
isHook(codePathFunctionName))
) {
return true;
}

const codePathFunctionNameSkippingCallExpressions =
getFunctionNameSkippingCallExpressions(codePathNode);
if (
codePathFunctionNameSkippingCallExpressions &&
isComponentName(codePathFunctionNameSkippingCallExpressions)
) {
return true;
}

return false;
})();

// Compute the earliest finalizer level using information from the
// cache. We expect all reachable final segments to have a cache entry
Expand Down Expand Up @@ -882,6 +877,61 @@ function getFunctionName(node: Node) {
}
}

/**
* Gets the static name of a function that is passed as an argument to one or more
* wrapper function calls. This function traverses up the AST through multiple levels
* of CallExpression nodes to find the ultimate variable assignment.
*
* This is used to identify React components that are wrapped in higher-order components
* or other wrapper functions like React.memo, React.forwardRef, or custom wrappers.
*
* The function works by:
* 1. Starting with the given function node
* 2. Traversing up through parent CallExpression nodes while the current node
* is an argument to those calls
* 3. Stopping when it reaches a VariableDeclarator that assigns the final
* CallExpression result to a variable
* 4. Returning the variable identifier if found
*
* This enables the rules-of-hooks linter to properly identify components even
* when they are deeply nested in wrapper function calls, allowing it to apply
* React Hook rules correctly.
*/

function getFunctionNameSkippingCallExpressions(node: Node) {
if (
node.type === 'FunctionExpression' ||
node.type === 'ArrowFunctionExpression'
) {
let current: Node = node;
let parent = node.parent;

// Skip through multiple chained CallExpressions
while (
parent?.type === 'CallExpression' &&
parent.arguments.includes(current)
) {
current = parent;
parent = parent.parent;
}

if (parent?.type === 'VariableDeclarator' && parent.init === current) {
// const ComponentName = memo(() => {});
// const ComponentName = forwardRef(() => {});
// const ComponentName = anyWrapper(() => {});
// const ComponentName = anyWrapper1(anyWrapper2(() => {}));
//
// This handles the case where a function is passed as an argument to
// one or more wrapper functions that are assigned to a component-named variable.
return parent.id;
} else {
return undefined;
}
} else {
return undefined;
}
}

/**
* Convenience function for peeking the last item in a stack.
*/
Expand Down