Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0357e0abed | ||
|
|
ac4ac1f5a6 |
@@ -1485,6 +1485,70 @@ const tests = {
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Test settings-based additionalHooks - should work with settings
|
||||||
|
code: normalizeIndent`
|
||||||
|
function MyComponent(props) {
|
||||||
|
useCustomEffect(() => {
|
||||||
|
console.log(props.foo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
settings: {
|
||||||
|
'react-hooks': {
|
||||||
|
additionalEffectHooks: 'useCustomEffect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Test settings-based additionalHooks - should work with dependencies
|
||||||
|
code: normalizeIndent`
|
||||||
|
function MyComponent(props) {
|
||||||
|
useCustomEffect(() => {
|
||||||
|
console.log(props.foo);
|
||||||
|
}, [props.foo]);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
settings: {
|
||||||
|
'react-hooks': {
|
||||||
|
additionalEffectHooks: 'useCustomEffect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Test that rule-level additionalHooks takes precedence over settings
|
||||||
|
code: normalizeIndent`
|
||||||
|
function MyComponent(props) {
|
||||||
|
useCustomEffect(() => {
|
||||||
|
console.log(props.foo);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
options: [{additionalHooks: 'useAnotherEffect'}],
|
||||||
|
settings: {
|
||||||
|
'react-hooks': {
|
||||||
|
additionalEffectHooks: 'useCustomEffect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Test settings with multiple hooks pattern
|
||||||
|
code: normalizeIndent`
|
||||||
|
function MyComponent(props) {
|
||||||
|
useCustomEffect(() => {
|
||||||
|
console.log(props.foo);
|
||||||
|
}, [props.foo]);
|
||||||
|
useAnotherEffect(() => {
|
||||||
|
console.log(props.bar);
|
||||||
|
}, [props.bar]);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
settings: {
|
||||||
|
'react-hooks': {
|
||||||
|
additionalEffectHooks: '(useCustomEffect|useAnotherEffect)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
invalid: [
|
invalid: [
|
||||||
{
|
{
|
||||||
@@ -3714,6 +3778,40 @@ const tests = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Test settings-based additionalHooks - should detect missing dependency
|
||||||
|
code: normalizeIndent`
|
||||||
|
function MyComponent(props) {
|
||||||
|
useCustomEffect(() => {
|
||||||
|
console.log(props.foo);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
settings: {
|
||||||
|
'react-hooks': {
|
||||||
|
additionalEffectHooks: 'useCustomEffect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"React Hook useCustomEffect has a missing dependency: 'props.foo'. " +
|
||||||
|
'Either include it or remove the dependency array.',
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
desc: 'Update the dependencies array to be: [props.foo]',
|
||||||
|
output: normalizeIndent`
|
||||||
|
function MyComponent(props) {
|
||||||
|
useCustomEffect(() => {
|
||||||
|
console.log(props.foo);
|
||||||
|
}, [props.foo]);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
code: normalizeIndent`
|
code: normalizeIndent`
|
||||||
function MyComponent() {
|
function MyComponent() {
|
||||||
|
|||||||
@@ -581,6 +581,27 @@ const allTests = {
|
|||||||
};
|
};
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: normalizeIndent`
|
||||||
|
// Valid: useEffectEvent can be called in custom effect hooks configured via ESLint settings
|
||||||
|
function MyComponent({ theme }) {
|
||||||
|
const onClick = useEffectEvent(() => {
|
||||||
|
showNotification(theme);
|
||||||
|
});
|
||||||
|
useMyEffect(() => {
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
useServerEffect(() => {
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
settings: {
|
||||||
|
'react-hooks': {
|
||||||
|
additionalEffectHooks: '(useMyEffect|useServerEffect)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
invalid: [
|
invalid: [
|
||||||
{
|
{
|
||||||
@@ -1353,6 +1374,39 @@ const allTests = {
|
|||||||
`,
|
`,
|
||||||
errors: [tryCatchUseError('use')],
|
errors: [tryCatchUseError('use')],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: normalizeIndent`
|
||||||
|
// Invalid: useEffectEvent should not be callable in regular custom hooks without additional configuration
|
||||||
|
function MyComponent({ theme }) {
|
||||||
|
const onClick = useEffectEvent(() => {
|
||||||
|
showNotification(theme);
|
||||||
|
});
|
||||||
|
useCustomHook(() => {
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
errors: [useEffectEventError('onClick', true)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: normalizeIndent`
|
||||||
|
// Invalid: useEffectEvent should not be callable in hooks not matching the settings regex
|
||||||
|
function MyComponent({ theme }) {
|
||||||
|
const onClick = useEffectEvent(() => {
|
||||||
|
showNotification(theme);
|
||||||
|
});
|
||||||
|
useWrongHook(() => {
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
settings: {
|
||||||
|
'react-hooks': {
|
||||||
|
additionalEffectHooks: 'useMyEffect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: [useEffectEventError('onClick', true)],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import type {
|
|||||||
VariableDeclarator,
|
VariableDeclarator,
|
||||||
} from 'estree';
|
} from 'estree';
|
||||||
|
|
||||||
|
import { getAdditionalEffectHooksFromSettings } from '../shared/Utils';
|
||||||
|
|
||||||
type DeclaredDependency = {
|
type DeclaredDependency = {
|
||||||
key: string;
|
key: string;
|
||||||
node: Node;
|
node: Node;
|
||||||
@@ -69,19 +71,22 @@ const rule = {
|
|||||||
},
|
},
|
||||||
requireExplicitEffectDeps: {
|
requireExplicitEffectDeps: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
create(context: Rule.RuleContext) {
|
create(context: Rule.RuleContext) {
|
||||||
const rawOptions = context.options && context.options[0];
|
const rawOptions = context.options && context.options[0];
|
||||||
|
const settings = context.settings || {};
|
||||||
|
|
||||||
|
|
||||||
// Parse the `additionalHooks` regex.
|
// Parse the `additionalHooks` regex.
|
||||||
|
// Use rule-level additionalHooks if provided, otherwise fall back to settings
|
||||||
const additionalHooks =
|
const additionalHooks =
|
||||||
rawOptions && rawOptions.additionalHooks
|
rawOptions && rawOptions.additionalHooks
|
||||||
? new RegExp(rawOptions.additionalHooks)
|
? new RegExp(rawOptions.additionalHooks)
|
||||||
: undefined;
|
: getAdditionalEffectHooksFromSettings(settings);
|
||||||
|
|
||||||
const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean =
|
const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean =
|
||||||
(rawOptions &&
|
(rawOptions &&
|
||||||
@@ -93,7 +98,8 @@ const rule = {
|
|||||||
? rawOptions.experimental_autoDependenciesHooks
|
? rawOptions.experimental_autoDependenciesHooks
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const requireExplicitEffectDeps: boolean = rawOptions && rawOptions.requireExplicitEffectDeps || false;
|
const requireExplicitEffectDeps: boolean =
|
||||||
|
(rawOptions && rawOptions.requireExplicitEffectDeps) || false;
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
additionalHooks,
|
additionalHooks,
|
||||||
@@ -1351,7 +1357,7 @@ const rule = {
|
|||||||
node: reactiveHook,
|
node: reactiveHook,
|
||||||
message:
|
message:
|
||||||
`React Hook ${reactiveHookName} always requires dependencies. ` +
|
`React Hook ${reactiveHookName} always requires dependencies. ` +
|
||||||
`Please add a dependency array or an explicit \`undefined\``
|
`Please add a dependency array or an explicit \`undefined\``,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
|
|
||||||
// @ts-expect-error untyped module
|
// @ts-expect-error untyped module
|
||||||
import CodePathAnalyzer from '../code-path-analysis/code-path-analyzer';
|
import CodePathAnalyzer from '../code-path-analysis/code-path-analyzer';
|
||||||
|
import { getAdditionalEffectHooksFromSettings } from '../shared/Utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Catch all identifiers that begin with "use" followed by an uppercase Latin
|
* Catch all identifiers that begin with "use" followed by an uppercase Latin
|
||||||
@@ -147,8 +148,23 @@ function getNodeWithoutReactNamespace(
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEffectIdentifier(node: Node): boolean {
|
function isEffectIdentifier(node: Node, additionalHooks?: RegExp): boolean {
|
||||||
return node.type === 'Identifier' && (node.name === 'useEffect' || node.name === 'useLayoutEffect' || node.name === 'useInsertionEffect');
|
const isBuiltInEffect =
|
||||||
|
node.type === 'Identifier' &&
|
||||||
|
(node.name === 'useEffect' ||
|
||||||
|
node.name === 'useLayoutEffect' ||
|
||||||
|
node.name === 'useInsertionEffect');
|
||||||
|
|
||||||
|
if (isBuiltInEffect) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this matches additional hooks configured by the user
|
||||||
|
if (additionalHooks && node.type === 'Identifier') {
|
||||||
|
return additionalHooks.test(node.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
function isUseEffectEventIdentifier(node: Node): boolean {
|
function isUseEffectEventIdentifier(node: Node): boolean {
|
||||||
if (__EXPERIMENTAL__) {
|
if (__EXPERIMENTAL__) {
|
||||||
@@ -169,8 +185,23 @@ const rule = {
|
|||||||
recommended: true,
|
recommended: true,
|
||||||
url: 'https://react.dev/reference/rules/rules-of-hooks',
|
url: 'https://react.dev/reference/rules/rules-of-hooks',
|
||||||
},
|
},
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
additionalHooks: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
create(context: Rule.RuleContext) {
|
create(context: Rule.RuleContext) {
|
||||||
|
const settings = context.settings || {};
|
||||||
|
|
||||||
|
const additionalEffectHooks = getAdditionalEffectHooksFromSettings(settings);
|
||||||
|
|
||||||
let lastEffect: CallExpression | null = null;
|
let lastEffect: CallExpression | null = null;
|
||||||
const codePathReactHooksMapStack: Array<
|
const codePathReactHooksMapStack: Array<
|
||||||
Map<Rule.CodePathSegment, Array<Node>>
|
Map<Rule.CodePathSegment, Array<Node>>
|
||||||
@@ -726,7 +757,7 @@ const rule = {
|
|||||||
// Check all `useEffect` and `React.useEffect`, `useEffectEvent`, and `React.useEffectEvent`
|
// Check all `useEffect` and `React.useEffect`, `useEffectEvent`, and `React.useEffectEvent`
|
||||||
const nodeWithoutNamespace = getNodeWithoutReactNamespace(node.callee);
|
const nodeWithoutNamespace = getNodeWithoutReactNamespace(node.callee);
|
||||||
if (
|
if (
|
||||||
(isEffectIdentifier(nodeWithoutNamespace) ||
|
(isEffectIdentifier(nodeWithoutNamespace, additionalEffectHooks) ||
|
||||||
isUseEffectEventIdentifier(nodeWithoutNamespace)) &&
|
isUseEffectEventIdentifier(nodeWithoutNamespace)) &&
|
||||||
node.arguments.length > 0
|
node.arguments.length > 0
|
||||||
) {
|
) {
|
||||||
|
|||||||
22
packages/eslint-plugin-react-hooks/src/shared/Utils.ts
Normal file
22
packages/eslint-plugin-react-hooks/src/shared/Utils.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Rule } from 'eslint';
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'react-hooks';
|
||||||
|
const SETTINGS_ADDITIONAL_EFFECT_HOOKS_KEY = 'additionalEffectHooks';
|
||||||
|
|
||||||
|
export function getAdditionalEffectHooksFromSettings(
|
||||||
|
settings: Rule.RuleContext['settings'],
|
||||||
|
): RegExp | undefined {
|
||||||
|
const additionalHooks = settings[SETTINGS_KEY]?.[SETTINGS_ADDITIONAL_EFFECT_HOOKS_KEY];
|
||||||
|
if (additionalHooks != null && typeof additionalHooks === 'string') {
|
||||||
|
return new RegExp(additionalHooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user