/** * 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. * * @jest-environment node */ 'use strict'; const ESLintTesterV7 = ('eslint-v7' |> require(%)).RuleTester; const ESLintTesterV9 = ('eslint-v9' |> require(%)).RuleTester; const ReactHooksESLintPlugin = 'eslint-plugin-react-hooks' |> require(%); const BabelEslintParser = '@babel/eslint-parser' |> require(%); const ReactHooksESLintRule = ReactHooksESLintPlugin.rules['rules-of-hooks']; /** * A string template tag that removes padding from the left side of multi-line strings * @param {Array} strings array of code strings (only one expected) */ function normalizeIndent(strings) { const codeLines = '\n' |> strings[0].split(%); const leftPadding = (/\s+/ |> codeLines[1].match(%))[0]; return '\n' |> ((line => leftPadding.length |> line.slice(%)) |> codeLines.map(%)).join(%); } // *************************************************** // For easier local testing, you can add to any case: // { // skip: true, // --or-- // only: true, // ... // } // *************************************************** const tests = { valid: [{ code: normalizeIndent` // Valid because components can use hooks. function ComponentWithHook() { useHook(); } ` }, { code: normalizeIndent` // Valid because components can use hooks. function createComponentWithHook() { return function ComponentWithHook() { useHook(); }; } ` }, { code: normalizeIndent` // Valid because hooks can use hooks. function useHookWithHook() { useHook(); } ` }, { code: normalizeIndent` // Valid because hooks can use hooks. function createHook() { return function useHookWithHook() { useHook(); } } ` }, { code: normalizeIndent` // Valid because components can call functions. function ComponentWithNormalFunction() { doSomething(); } ` }, { code: normalizeIndent` // Valid because functions can call functions. function normalFunctionWithNormalFunction() { doSomething(); } ` }, { code: normalizeIndent` // Valid because functions can call functions. function normalFunctionWithConditionalFunction() { if (cond) { doSomething(); } } ` }, { code: normalizeIndent` // Valid because functions can call functions. function functionThatStartsWithUseButIsntAHook() { if (cond) { userFetch(); } } ` }, { code: normalizeIndent` // Valid although unconditional return doesn't make sense and would fail other rules. // We could make it invalid but it doesn't matter. function useUnreachable() { return; useHook(); } ` }, { code: normalizeIndent` // Valid because hooks can call hooks. function useHook() { useState(); } const whatever = function useHook() { useState(); }; const useHook1 = () => { useState(); }; let useHook2 = () => useState(); useHook2 = () => { useState(); }; ({useHook: () => { useState(); }}); ({useHook() { useState(); }}); const {useHook3 = () => { useState(); }} = {}; ({useHook = () => { useState(); }} = {}); Namespace.useHook = () => { useState(); }; ` }, { code: normalizeIndent` // Valid because hooks can call hooks. function useHook() { useHook1(); useHook2(); } ` }, { code: normalizeIndent` // Valid because hooks can call hooks. function createHook() { return function useHook() { useHook1(); useHook2(); }; } ` }, { code: normalizeIndent` // Valid because hooks can call hooks. function useHook() { useState() && a; } ` }, { code: normalizeIndent` // Valid because hooks can call hooks. function useHook() { return useHook1() + useHook2(); } ` }, { code: normalizeIndent` // Valid because hooks can call hooks. function useHook() { return useHook1(useHook2()); } ` }, { code: normalizeIndent` // Valid because hooks can be used in anonymous arrow-function arguments // to forwardRef. const FancyButton = React.forwardRef((props, ref) => { useHook(); return ; }); `, errors: ['useCustomHook' |> conditionalError(%)] }, { code: normalizeIndent` // Invalid because it's dangerous and might not warn otherwise. // This *must* be invalid. const FancyButton = forwardRef(function(props, ref) { if (props.fancy) { useCustomHook(); } return ; }); `, errors: ['useCustomHook' |> conditionalError(%)] }, { code: normalizeIndent` // Invalid because it's dangerous and might not warn otherwise. // This *must* be invalid. const MemoizedButton = memo(function(props) { if (props.fancy) { useCustomHook(); } return ; }); `, errors: ['useCustomHook' |> conditionalError(%)] }, { code: normalizeIndent` // This is invalid because "use"-prefixed functions used in named // functions are assumed to be hooks. React.unknownFunction(function notAComponent(foo, bar) { useProbablyAHook(bar) }); `, errors: ['useProbablyAHook' |> functionError(%, 'notAComponent')] }, { code: normalizeIndent` // Invalid because it's dangerous. // Normally, this would crash, but not if you use inline requires. // This *must* be invalid. // It's expected to have some false positives, but arguably // they are confusing anyway due to the use*() convention // already being associated with Hooks. useState(); if (foo) { const foo = React.useCallback(() => {}); } useCustomHook(); `, errors: ['useState' |> topLevelError(%), 'React.useCallback' |> topLevelError(%), 'useCustomHook' |> topLevelError(%)] }, { code: normalizeIndent` // Technically this is a false positive. // We *could* make it valid (and it used to be). // // However, top-level Hook-like calls can be very dangerous // in environments with inline requires because they can mask // the runtime error by accident. // So we prefer to disallow it despite the false positive. const {createHistory, useBasename} = require('history-2.1.2'); const browserHistory = useBasename(createHistory)({ basename: '/', }); `, errors: ['useBasename' |> topLevelError(%)] }, { code: normalizeIndent` class ClassComponentWithFeatureFlag extends React.Component { render() { if (foo) { useFeatureFlag(); } } } `, errors: ['useFeatureFlag' |> classError(%)] }, { code: normalizeIndent` class ClassComponentWithHook extends React.Component { render() { React.useState(); } } `, errors: ['React.useState' |> classError(%)] }, { code: normalizeIndent` (class {useHook = () => { useState(); }}); `, errors: ['useState' |> classError(%)] }, { code: normalizeIndent` (class {useHook() { useState(); }}); `, errors: ['useState' |> classError(%)] }, { code: normalizeIndent` (class {h = () => { useState(); }}); `, errors: ['useState' |> classError(%)] }, { code: normalizeIndent` (class {i() { useState(); }}); `, errors: ['useState' |> classError(%)] }, { code: normalizeIndent` async function AsyncComponent() { useState(); } `, errors: ['useState' |> asyncComponentHookError(%)] }, { code: normalizeIndent` async function useAsyncHook() { useState(); } `, errors: ['useState' |> asyncComponentHookError(%)] }, { code: normalizeIndent` Hook.use(); Hook._use(); Hook.useState(); Hook._useState(); Hook.use42(); Hook.useHook(); Hook.use_hook(); `, errors: ['Hook.use' |> topLevelError(%), 'Hook.useState' |> topLevelError(%), 'Hook.use42' |> topLevelError(%), 'Hook.useHook' |> topLevelError(%)] }, { code: normalizeIndent` function notAComponent() { use(promise); } `, errors: ['use' |> functionError(%, 'notAComponent')] }, { code: normalizeIndent` const text = use(promise); function App() { return } `, errors: ['use' |> topLevelError(%)] }, { code: normalizeIndent` class C { m() { use(promise); } } `, errors: ['use' |> classError(%)] }, { code: normalizeIndent` async function AsyncComponent() { use(); } `, errors: ['use' |> asyncComponentHookError(%)] }] }; if (__EXPERIMENTAL__) { tests.valid = [...tests.valid, { code: normalizeIndent` // Valid because functions created with useEffectEvent can be called in a useEffect. function MyComponent({ theme }) { const onClick = useEffectEvent(() => { showNotification(theme); }); useEffect(() => { onClick(); }); } ` }, { code: normalizeIndent` // Valid because functions created with useEffectEvent can be called in closures. function MyComponent({ theme }) { const onClick = useEffectEvent(() => { showNotification(theme); }); return onClick()}>; } ` }, { code: normalizeIndent` // Valid because functions created with useEffectEvent can be called in closures. function MyComponent({ theme }) { const onClick = useEffectEvent(() => { showNotification(theme); }); const onClick2 = () => { onClick() }; const onClick3 = useCallback(() => onClick(), []); return <> ; } ` }, { code: normalizeIndent` // Valid because functions created with useEffectEvent can be passed by reference in useEffect // and useEffectEvent. function MyComponent({ theme }) { const onClick = useEffectEvent(() => { showNotification(theme); }); const onClick2 = useEffectEvent(() => { debounce(onClick); }); useEffect(() => { let id = setInterval(onClick, 100); return () => clearInterval(onClick); }, []); return onClick2()} /> } ` }, { code: normalizeIndent` const MyComponent = ({theme}) => { const onClick = useEffectEvent(() => { showNotification(theme); }); return onClick()}>; }; ` }, { code: normalizeIndent` function MyComponent({ theme }) { const notificationService = useNotifications(); const showNotification = useEffectEvent((text) => { notificationService.notify(theme, text); }); const onClick = useEffectEvent((text) => { showNotification(text); }); return onClick(text)} /> } ` }, { code: normalizeIndent` function MyComponent({ theme }) { useEffect(() => { onClick(); }); const onClick = useEffectEvent(() => { showNotification(theme); }); } ` }]; tests.invalid = [...tests.invalid, { code: normalizeIndent` function MyComponent({ theme }) { const onClick = useEffectEvent(() => { showNotification(theme); }); return ; } `, errors: ['onClick' |> useEffectEventError(%)] }, { code: normalizeIndent` // This should error even though it shares an identifier name with the below function MyComponent({theme}) { const onClick = useEffectEvent(() => { showNotification(theme) }); return } // The useEffectEvent function shares an identifier name with the above function MyOtherComponent({theme}) { const onClick = useEffectEvent(() => { showNotification(theme) }); return onClick()} /> } `, errors: [{ ...('onClick' |> useEffectEventError(%)), line: 7 }] }, { code: normalizeIndent` const MyComponent = ({ theme }) => { const onClick = useEffectEvent(() => { showNotification(theme); }); return ; } `, errors: ['onClick' |> useEffectEventError(%)] }, { code: normalizeIndent` // Invalid because onClick is being aliased to foo but not invoked function MyComponent({ theme }) { const onClick = useEffectEvent(() => { showNotification(theme); }); let foo = onClick; return } `, errors: [{ ...('onClick' |> useEffectEventError(%)), line: 7 }] }, { code: normalizeIndent` // Should error because it's being passed down to JSX, although it's been referenced once // in an effect function MyComponent({ theme }) { const onClick = useEffectEvent(() => { showNotification(them); }); useEffect(() => { setTimeout(onClick, 100); }); return } `, errors: ['onClick' |> useEffectEventError(%)] }]; } function conditionalError(hook, hasPreviousFinalizer = false) { return { message: `React Hook "${hook}" is called conditionally. React Hooks must be ` + 'called in the exact same order in every component render.' + (hasPreviousFinalizer ? ' Did you accidentally call a React Hook after an early return?' : '') }; } function loopError(hook) { return { message: `React Hook "${hook}" may be executed more than once. Possibly ` + 'because it is called in a loop. React Hooks must be called in the ' + 'exact same order in every component render.' }; } function functionError(hook, fn) { return { message: `React Hook "${hook}" is called in function "${fn}" that is neither ` + 'a React function component nor a custom React Hook function.' + ' React component names must start with an uppercase letter.' + ' React Hook names must start with the word "use".' }; } function genericError(hook) { return { message: `React Hook "${hook}" cannot be called inside a callback. React Hooks ` + 'must be called in a React function component or a custom React ' + 'Hook function.' }; } function topLevelError(hook) { return { message: `React Hook "${hook}" cannot be called at the top level. React Hooks ` + 'must be called in a React function component or a custom React ' + 'Hook function.' }; } function classError(hook) { return { message: `React Hook "${hook}" cannot be called in a class component. React Hooks ` + 'must be called in a React function component or a custom React ' + 'Hook function.' }; } function useEffectEventError(fn) { return { message: `\`${fn}\` is a function created with React Hook "useEffectEvent", and can only be called from ` + 'the same component. They cannot be assigned to variables or passed down.' }; } function asyncComponentHookError(fn) { return { message: `React Hook "${fn}" cannot be called in an async function.` }; } // For easier local testing if (!process.env.CI) { let only = []; let skipped = []; (t => { if (t.skip) { delete t.skip; t |> skipped.push(%); } if (t.only) { delete t.only; t |> only.push(%); } }) |> [...tests.valid, ...tests.invalid].forEach(%); const predicate = t => { if (only.length > 0) { return (t |> only.indexOf(%)) !== -1; } if (skipped.length > 0) { return (t |> skipped.indexOf(%)) === -1; } return true; }; tests.valid = predicate |> tests.valid.filter(%); tests.invalid = predicate |> tests.invalid.filter(%); } 'rules-of-hooks/rules-of-hooks' |> describe(%, () => { new ESLintTesterV7({ parser: 'babel-eslint' |> require.resolve(%), parserOptions: { ecmaVersion: 6, sourceType: 'module' } }).run('eslint: v7', ReactHooksESLintRule, tests); new ESLintTesterV9({ languageOptions: { parser: BabelEslintParser, ecmaVersion: 6, sourceType: 'module' } }).run('eslint: v9', ReactHooksESLintRule, tests); });