Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add no-nested-components ESLint rule #25360

Closed
wants to merge 5 commits into from

Conversation

eps1lon
Copy link
Collaborator

@eps1lon eps1lon commented Sep 29, 2022

Summary

Adds a new no-nested-components rule to eslint-plugin-react-hooks that triggers on nested component declarations e.g.

function Component() {
  const Nested = () => <div />;
  // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 
  // Component "Nested" is declared during render. 
  // You should move this declaration outside of render to ensure this component's state is persisted across re-renders of its parent. 
  // If this is not a component, or not inside a component or hook, rename it to make sure it is not mistaken for a component e.g. `Icon` -> `renderIcon`.

  return <Nested />
}

These declarations are problematic since the nested components will not persist state across re-renders of the parent component.

The tests are copied from react/no-nested-unstable-components but re-ordered since we currently apply the same logic as rules-of-hooks: If it looks like a component and is inside a component or hooks it cannot be nested inside another component.

Unlike react/no-nested-unstable-components we currently don't detect nested component definitions in class components nor do we detect nested class component definitions. Both of these scenarios would be nice to cover as well though!

Open questions:

  • releaseable? should more cases be covered? better package (name)?
  • rule name bikeshedding
  • internal: inline helpers from RulesOfHooks or created shared helpers?
  • How do we communicate forking of lint rules? What happens in code bases with both react/no-unstable-nested-components and react-hooks/no-nested-components?

How did you test this change?

Why not recommend react/no-nested-unstable-components

  • Questionable if this rule will be part of the recommended ruleset in a timely manner.
  • Consistent detection of what's considered a component (e.g. we can detect nested forwardRef or memo component declarations )
  • no-unstable-nested-components is not actually what we want. It's just about nesting components. Memoizing them will still cause breakage since their state won't be re-useable (remember that useMemo/useCallback are perf optimizations not semantic guarantees unlike key and component types)

Not detected in proposed rule but detected in react/no-unstable-nested-components

A. <Component footer={() => <div />} />

This is probably correct to no longer detect since const footer = () => <div />} /> would also not be considered a component. However, <Component Footer={() => <div />} /> would also not be detected. This would probably be nice to get into Rules of Hooks regardless.

Detected in proposed rule but not detected in react/no-unstable-nested-components

A.

function useHook() {
   return function Component() {}
}

Intended. Hooks are called during render which means the component declaration happens during render.

B.

function Component() {
   const ConditionalWrapper = ({ condition, wrapper, children }: any) =>
      condition ? wrapper(children) : children
}

Intended. Rules of Hooks would allow calling a Hook in ConditionalWrapper so we should consider it a nested declaration.

C.

function Component() {
  const components = {
    ActionIcon: () => null,
  }
}

Intended. Even though flagging this particular example seems silly, Rules of Hooks would allow calling a Hook in ActionIcon so we should consider it a nested declaration.

@sizebot
Copy link

sizebot commented Sep 29, 2022

Comparing: abd7bcd...e002fc7

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.min.js = 135.28 kB 135.28 kB = 43.39 kB 43.39 kB
oss-experimental/react-dom/cjs/react-dom.production.min.js = 142.74 kB 142.74 kB = 45.58 kB 45.58 kB
facebook-www/ReactDOM-prod.classic.js = 490.70 kB 490.70 kB = 87.32 kB 87.32 kB
facebook-www/ReactDOM-prod.modern.js = 475.99 kB 475.99 kB = 85.13 kB 85.13 kB
facebook-www/ReactDOMForked-prod.classic.js = 490.70 kB 490.70 kB = 87.32 kB 87.32 kB
oss-stable-semver/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.production.min.js +9.79% 26.33 kB 28.90 kB +5.21% 8.95 kB 9.42 kB
oss-stable/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.production.min.js +9.79% 26.33 kB 28.90 kB +5.21% 8.95 kB 9.42 kB
oss-experimental/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.production.min.js +9.63% 26.78 kB 29.36 kB +5.23% 9.10 kB 9.58 kB
oss-stable-semver/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js +7.75% 91.00 kB 98.06 kB +3.11% 21.53 kB 22.20 kB
oss-stable/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js +7.75% 91.00 kB 98.06 kB +3.11% 21.53 kB 22.20 kB
oss-experimental/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js +7.74% 91.13 kB 98.18 kB +3.12% 21.55 kB 22.22 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable-semver/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.production.min.js +9.79% 26.33 kB 28.90 kB +5.21% 8.95 kB 9.42 kB
oss-stable/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.production.min.js +9.79% 26.33 kB 28.90 kB +5.21% 8.95 kB 9.42 kB
oss-experimental/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.production.min.js +9.63% 26.78 kB 29.36 kB +5.23% 9.10 kB 9.58 kB
oss-stable-semver/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js +7.75% 91.00 kB 98.06 kB +3.11% 21.53 kB 22.20 kB
oss-stable/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js +7.75% 91.00 kB 98.06 kB +3.11% 21.53 kB 22.20 kB
oss-experimental/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js +7.74% 91.13 kB 98.18 kB +3.12% 21.55 kB 22.22 kB

Generated by 🚫 dangerJS against e002fc7

Copy link
Contributor

@AriPerkkio AriPerkkio left a comment

Choose a reason for hiding this comment

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

I ran this through 2000 repositories. No crashes but some false positives. Most are from uppercase functions which do not return JSX - the eslint-plugin-react/no-unstable-nested-components rule especially checks whether function is returning JSX or React.createElement. Maybe this one should do too.

Copy-paste ready test cases:

{
  code: normalizeIndent`
    function ParentComponent() {
      function NotComponent() {
        console.log('Not returning JSX');
      }

      return <button onClick={NotComponent} />;
    }
  `,
},
{
  code: normalizeIndent`
    function ParentComponent() {
      const inputKeyDownHandlers = useMemo(() => ({
        ArrowDown(event) {
          event.preventDefault();
          // Do something
        },
        ArrowUp(event) {
          event.preventDefault();
          // Do something
        },
      }));
  
      return <ComplexComponent handlers={inputKeyDownHandlers} />;
    }
  `,
},
{
  code: normalizeIndent`
    const MyComponent = React.memo(
      React.forwardRef((props, ref) => {
        return <div />
      })
    )
  `,
},
Uppercase component not returning JSX

Rule: react-hooks/no-nested-components

  • Message: Component "CleanupInput" is declared during render. You should move this declaration outside of render to ensure this component's state is persisted across re-renders of its parent. If this component is memoized, you should still refactor the component to be able to move it outside of render. If you want to reset its state use a key instead (see https://reactjs.org/docs/lists-and-keys.html#keys).
  • Path: axmz/react-searchbox-awesome/src/components/Search.jsx
  • Link
  82 |
  83 |   // Cleanup input
> 84 |   function CleanupInput() {
     |   ^^^^^^^^^^^^^^^^^^^^^^^^^
> 85 |     inputRef.current.value = ""
     | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 86 |     inputRef.current.dispatchEvent(inputEvent)
     | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 87 |     inputRef.current.focus();
     | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 88 |   }
     | ^^^^
  89 |
  90 |   // Click handler
  91 |   const clickHandler = (e) => {
Memo + forwardRef

Rule: react-hooks/no-nested-components

  • Message: Component "Unknown" is declared during render. You should move this declaration outside of render to ensure this component's state is persisted across re-renders of its parent. If this component is memoized, you should still refactor the component to be able to move it outside of render. If you want to reset its state use a key instead (see https://reactjs.org/docs/lists-and-keys.html#keys).
  • Path: alloc/wana/packages/babel-plugin-add-react-displayname/spec/fixtures/multipleWraps.tsx
  • Link
  1 | export const Test1 = React.memo(
> 2 |   React.forwardRef((props, ref) => {
    |                    ^^^^^^^^^^^^^^^^^
> 3 |     return <div />
    | ^^^^^^^^^^^^^^^^^^
> 4 |   })
    | ^^^^
  5 | )
  6 |
  7 | const Test2 = React.memo(
Hook-like, not returninig JSX

Rule: react-hooks/no-nested-components

  • Message: Component "useBlock" is declared during render. You should move this declaration outside of render to ensure this component's state is persisted across re-renders of its parent. If this component is memoized, you should still refactor the component to be able to move it outside of render. If you want to reset its state use a key instead (see https://reactjs.org/docs/lists-and-keys.html#keys).
  • Path: BuilderIO/jsx-lite/packages/core/src/__tests__/data/blocks/builder-render-block.raw.tsx
  • Link
  46 |       return getBlockProperties(state.useBlock);
  47 |     },
> 48 |     get useBlock() {
     |                 ^^^^
> 49 |       return getProcessedBlock({
     | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 50 |         block: props.block,
     | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 51 |         state: builderContext.state,
     | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 52 |         context: builderContext.context,
     | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 53 |       });
     | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 54 |     },
     | ^^^^^^
  55 |     get actions() {
  56 |       return getBlockActions({
  57 |         block: state.useBlock,
Memoized objcet with uppercase functions

Rule: react-hooks/no-nested-components

  • Message: Component "ArrowDown" is declared during render. You should move this declaration outside of render to ensure this component's state is persisted across re-renders of its parent. If this component is memoized, you should still refactor the component to be able to move it outside of render. If you want to reset its state use a key instead (see https://reactjs.org/docs/lists-and-keys.html#keys).
  • Path: downshift-js/downshift/src/hooks/useCombobox/index.js
  • Link
  153 |   const inputKeyDownHandlers = useMemo(
  154 |     () => ({
> 155 |       ArrowDown(event) {
      |                ^^^^^^^^^
> 156 |         event.preventDefault()
      | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 157 |         dispatch({
      | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 158 |           type: stateChangeTypes.InputKeyDownArrowDown,
      | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 159 |           shiftKey: event.shiftKey,
      | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 160 |           getItemNodeFromIndex,
      | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 161 |         })
      | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 162 |       },
      | ^^^^^^^^
  163 |       ArrowUp(event) {
  164 |         event.preventDefault()
  165 |         dispatch({

@eps1lon
Copy link
Collaborator Author

eps1lon commented Oct 1, 2022

I ran this through 2000 repositories.

How did you do that?

Memoized objcet with uppercase functions + Uppercase component not returning JSX

In these false-positives, what would the existing react-hooks rules say? The idea was that it should flag everything that could also contain a hooks call. So "false-positives" of this rule would be false-negatives of the other rules i.e. it's not considered a false-positive since we established that uppercase functions are components. In those cases the author should just rename ArrowDown to arrowDown.

Memo + forwardRef

The memo+forwardRef is definitely something we should not flag 👍🏻

Hook-like, not returninig JSX

That case needs a better message but is not a false-positive. The function is considered a hook and can thus be called during render.

@AriPerkkio
Copy link
Contributor

Memoized object with uppercase functions + Uppercase component not returning JSX

In these false-positives, what would the existing react-hooks rules say?

Existing eslint-plugin-react-hooks rules won't report anything in these cases. And the existing eslint-plugin-react/no-unstable-nested-components doesn't either since functions are not returning JSX or output of React.createElement.

In those cases the author should just rename ArrowDown to arrowDown.

I agree this should be the recommended solution but not sure how this could be informed to users. Now this rule would confuse users by calling these functions components. Instead the rule should detect that these are not returning JSX, and recommend users to rename the function into lowercase.

I ran this through 2000 repositories.

How did you do that?

Started your react fork in Github Codespaces and installed eslint-remote-tester. I'm always using it to see how rules under development work in real life projects. Unit tests are simply not enough.

eslint-remote-tester.config.js
const eslintRemoteTesterRepositories = require('eslint-remote-tester-repositories');

module.exports = {
  repositories: eslintRemoteTesterRepositories
    .getRepositories({ randomize: false })
    .slice(0, 2000),

  pathIgnorePattern: eslintRemoteTesterRepositories.getPathIgnorePattern(),

  extensions: ['.js', '.jsx', '.ts', '.tsx'],

  rulesUnderTesting: ['react-hooks/no-nested-components'],

  cache: false,

  eslintrc: {
    root: true,
    env: {
      es6: true,
    },
    parser: '@typescript-eslint/parser',

    parserOptions: {
      ecmaVersion: 2020,
      sourceType: 'module',
      ecmaFeatures: {
        jsx: true,
      },
    },
    plugins: ['react-hooks'],
    rules: {
      'react-hooks/no-nested-components': ['error'],
    },
  },
};

@eps1lon
Copy link
Collaborator Author

eps1lon commented Oct 1, 2022

Existing eslint-plugin-react-hooks rules won't report anything in these cases.

Which is the problem. React thinks these are function components and allows calling hooks. And if it's a component, it should not allow nested declarations. Either we start flagging hook calls in these cases or we continue flagging nested component declarations. But having an inconsistent detection is the worst scenario since it requires knowledge how individual rules detect components. When you should just need to know what the plugin (or rather React itself) considers a function component.

I agree this should be the recommended solution but not sure how this could be informed to users. Now this rule would confuse users by calling these functions components.

This has been the narrative since rules-of-hooks was released (see https://reactjs.org/docs/hooks-faq.html#what-exactly-do-the-lint-rules-enforce). We can amend the error message to explain why this is considered a component and how to fix it by renaming.

Instead the rule should detect that these are not returning JSX

A component does not need to return JSX. It can return primitives as well. I would not change this detection mechanism in this PR. It should be consistent with the existing rules-of-hooks. If there are real-world scenarios where renaming does not make sense, we can follow-up later.

@eps1lon eps1lon requested review from AriPerkkio, sophiebits, poteto and gaearon and removed request for AriPerkkio October 1, 2022 16:23
Copy link
Contributor

@AriPerkkio AriPerkkio left a comment

Choose a reason for hiding this comment

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

React thinks these are function components and allows calling hooks.
[...]
This has been the narrative since rules-of-hooks was released (see https://reactjs.org/docs/hooks-faq.html#what-exactly-do-the-lint-rules-enforce).

Oh I see, this is really good. Having a clear convention of what is considered as a component makes writing rules a lot easier.

I did some re-testing with the latest changes and did not see any other problems - a lot of valid ESLint reports did pop up.

But it's also worth to mention, that if this rule is used by default in ESLint configuration, it might start to flag a lot of issues in codebases at first. There are libraries which promote such pattern, e.g. react-table, https://gist.github.com/AriPerkkio/22bb98633d367156d090dafe376444ac. In all of these cases the components could be moved out of render block though.

Comment on lines +87 to +98
function Component() {
// nested component declaration
function UnstableNestedComponent() {
return <div />;
}

return (
<div>
<UnstableNestedComponent />
</div>
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Should all mentions of UnstableNestedComponent be simply just NestedComponent? There is no such thing as stable nested component. When I came up with this term, I falsely thought useCallback or useMemo would "stabilize" it.

Suggested change
function Component() {
// nested component declaration
function UnstableNestedComponent() {
return <div />;
}
return (
<div>
<UnstableNestedComponent />
</div>
);
}
function Component() {
// Nested component declaration
function NestedComponent() {
return <div />;
}
return (
<div>
<NestedComponent />
</div>
);
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good point. Just copied these tests but I should make a pass regarding naming

Copy link

This pull request has been automatically marked as stale. If this pull request is still relevant, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize reviewing it yet. Your contribution is very much appreciated.

@github-actions github-actions bot added the Resolution: Stale Automatically closed due to inactivity label Apr 10, 2024
Copy link

Closing this pull request after a prolonged period of inactivity. If this issue is still present in the latest release, please ask for this pull request to be reopened. Thank you!

@github-actions github-actions bot closed this Apr 17, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed Resolution: Stale Automatically closed due to inactivity
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants