Skip to content

Commit

Permalink
[New] no-unstable-nested-components: Prevent creating unstable comp…
Browse files Browse the repository at this point in the history
…onents inside components
  • Loading branch information
AriPerkkio committed Aug 15, 2020
1 parent 9abc71d commit ca684ef
Show file tree
Hide file tree
Showing 5 changed files with 927 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -131,6 +131,7 @@ Enable the rules that you would like to use.
* [react/no-unescaped-entities](docs/rules/no-unescaped-entities.md): Detect unescaped HTML entities, which might represent malformed tags
* [react/no-unknown-property](docs/rules/no-unknown-property.md): Prevent usage of unknown DOM property (fixable)
* [react/no-unsafe](docs/rules/no-unsafe.md): Prevent usage of unsafe lifecycle methods
* [react/no-unstable-nested-components](docs/rules/no-unstable-nested-components.md): Prevent creating unstable components inside components
* [react/no-unused-prop-types](docs/rules/no-unused-prop-types.md): Prevent definitions of unused prop types
* [react/no-unused-state](docs/rules/no-unused-state.md): Prevent definition of unused state fields
* [react/no-will-update-set-state](docs/rules/no-will-update-set-state.md): Prevent usage of setState in componentWillUpdate
Expand Down
135 changes: 135 additions & 0 deletions docs/rules/no-unstable-nested-components.md
@@ -0,0 +1,135 @@
# Prevent creating unstable components inside components (react/no-unstable-nested-components)

Creating components inside components without memoization leads to unstable components. The nested component and all its children are recreated during each re-render. Given stateful children of the nested component will lose their state on each re-render.

React reconcilation performs element type comparison with [reference equality](https://github.com/facebook/react/blob/v16.13.1/packages/react-reconciler/src/ReactChildFiber.js#L407). The reference to the same element changes on each re-render when defining components inside the render block. This leads to complete recreation of the current node and all its children. As a result the virtual DOM has to do extra unnecessary work and [possible bugs are introduced](https://codepen.io/ariperkkio/pen/vYLodLB).

## Rule Details

The following patterns are considered warnings:

```jsx
function Component() {
function UnstableNestedComponent() {
return <div />;
}

return (
<div>
<UnstableNestedComponent />
</div>
);
}
```

```jsx

function SomeComponent({ footer: Footer }) {
return (
<div>
<Footer />
</div>
);
}

function Component() {
return (
<div>
<SomeComponent footer={() => <div />} />
</div>
);
}
```

```jsx
class Component extends React.Component {
render() {
function UnstableNestedComponent() {
return <div />;
}

return (
<div>
<UnstableNestedComponent />
</div>
);
}
}
```

The following patterns are **not** considered warnings:

```jsx
function OutsideDefinedComponent(props) {
return <div />;
}

function Component() {
return (
<div>
<OutsideDefinedComponent />
</div>
);
}
```

By default component creation is allowed inside component props only if prop name starts with `render`. See `allowAsProps` option for disabling this limitation completely.

```jsx
function SomeComponent(props) {
return <div>{props.renderFooter()}</div>;
}

function Component() {
return (
<div>
<SomeComponent renderFooter={() => <div />} />
</div>
);
}
```

```jsx
function Component() {
const MemoizedNestedComponent = React.useCallback(() => <div />, []);

return (
<div>
<MemoizedNestedComponent />
</div>
);
}
```

## Rule Options

```js
...
"react/no-unstable-nested-components": [
"off" | "warn" | "error",
{ "allowAsProps": true | false }
]
...
```

You can allow component creation inside component props by setting `allowAsProps` option to true. When using this option make sure you are **calling** the props in the receiving component and not using them as elements.

The following patterns are **not** considered warnings:

```jsx
function SomeComponent(props) {
return <div>{props.footer()}</div>;
}

function Component() {
return (
<div>
<SomeComponent footer={() => <div />} />
</div>
);
}
```

## When Not To Use It

If you are not interested in preventing bugs related to re-creation of the nested components or do not care about optimization of virtual DOM.
1 change: 1 addition & 0 deletions index.js
Expand Up @@ -74,6 +74,7 @@ const allRules = {
'no-unescaped-entities': require('./lib/rules/no-unescaped-entities'),
'no-unknown-property': require('./lib/rules/no-unknown-property'),
'no-unsafe': require('./lib/rules/no-unsafe'),
'no-unstable-nested-components': require('./lib/rules/no-unstable-nested-components'),
'no-unused-prop-types': require('./lib/rules/no-unused-prop-types'),
'no-unused-state': require('./lib/rules/no-unused-state'),
'no-will-update-set-state': require('./lib/rules/no-will-update-set-state'),
Expand Down
222 changes: 222 additions & 0 deletions lib/rules/no-unstable-nested-components.js
@@ -0,0 +1,222 @@
/**
* @fileoverview Prevent creating unstable components inside components
* @author Ari Perkkiö
*/

'use strict';

const Components = require('../util/Components');
const docsUrl = require('../util/docsUrl');

// ------------------------------------------------------------------------------
// Constants
// ------------------------------------------------------------------------------

const ERROR_MESSAGE_WITHOUT_NAME = 'Declare them outside the component or memoize them.';
const COMPONENT_AS_PROPS_INFO = ' If you want to allow component creation in props, set allowAsProps option to true.';
function generateErrorMessageWithParentName(parentName) {
return `Declare this component outside parent component "${parentName}" or memoize it.`;
}

// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------

module.exports = {
meta: {
docs: {
description: 'Prevent creating unstable components inside components',
category: 'Possible Errors',
recommended: false,
url: docsUrl('no-unstable-nested-components')
},
schema: [{
type: 'object',
properties: {
customValidators: {
type: 'array',
items: {
type: 'string'
}
},
allowAsProps: {
type: 'boolean'
}
},
additionalProperties: false
}]
},

create: Components.detect((context, components, utils) => {
const allowAsProps = context.options.some((option) => option && option.allowAsProps);

/**
* Get closest parent of the given node.
* Walks up the tree recursively all the way to `Program`.
* @param {ASTNode} node The AST node
* @returns {ASTNode} Closest parent component of given node, if any.
*/
function getClosestParentComponentNode(node) {
if (!node.parent || node.parent.type === 'Program') {
return;
}

if (components.get(node.parent)) {
return node.parent;
}

return getClosestParentComponentNode(node.parent);
}

/**
* Resolve name of the parent component of given node
* @param {ASTNode} node The AST node
* @returns {String} Name of the parent component, if any
*/
function resolveParentComponentName(node) {
const parentName = node.id && node.id.name;

if (parentName) return parentName;

if (node.type === 'ArrowFunctionExpression' && node.parent) {
return node.parent.id && node.parent.id.name;
}
}

/**
* Check whether given node is inside class component's render block
* ```jsx
* class Component extends React.Component {
* render() {
* class NestedClassComponent extends React.Component {
* ...
* ```
* @param {ASTNode} node The AST node being checked
* @returns {Boolean} True if node is inside class component's render block, false if not
*/
function isInsideRenderMethod(node) {
const parentComponent = utils.getParentComponent();
if (!parentComponent || parentComponent.type !== 'ClassDeclaration') {
return false;
}

return (
node.parent.type === 'MethodDefinition'
&& node.parent.key
&& node.parent.key.name === 'render'
);
}

/**
* Check whether current node is a stateless component declared inside class component.
* Util's component detection fails to detect stateless components inside class components.
* ```jsx
* class Component extends React.Component {
* render() {
* const NestedComponent = () => <div />;
* ...
* ```
* @param {ASTNode} node The AST node being checked
* @returns {Boolean} True if current node a stateless component declared inside class component, false if not
*/
function isStatelessComponentInsideClassComponent(node) {
const parentComponent = utils.getParentComponent();
const parentStatelessComponent = utils.getParentStatelessComponent();

return (
parentComponent
&& parentStatelessComponent
&& parentComponent.type === 'ClassDeclaration'
&& utils.getStatelessComponent(parentStatelessComponent)
&& utils.isReturningJSX(node)
);
}

/**
* Check whether given node is declared inside component prop
* ```jsx
* <Component footer={() => <div />} />
* ```
* @param {ASTNode} node The AST node being checked
* @returns {Boolean} True if node is inside a component declared inside prop, false if not
*/
function isComponentInProp(node) {
if (allowAsProps || node.parent.type !== 'JSXExpressionContainer') {
return false;
}

const propNode = node.parent.parent;

// Check whether component is a render prop
if (propNode && propNode.type === 'JSXElement') {
return false;
}

// Check whether prop name starts with render, e.g. <Component renderFooter={() => <div />} />
if (
propNode
&& propNode.type === 'JSXAttribute'
&& propNode.name
&& propNode.name.type === 'JSXIdentifier'
&& /^render/.test(propNode.name.name || '')) {
return false;
}

return utils.isReturningJSX(node);
}

/**
* Check whether given node is a unstable nested component
* @param {ASTNode} node The AST node being checked
*/
function validate(node) {
if (!node || !node.parent) return;

const isDeclaredInsideProps = isComponentInProp(node);

if (
!components.get(node)
&& !isStatelessComponentInsideClassComponent(node)
&& !isDeclaredInsideProps) {
return;
}

// Prevent reporting nested class components twice
if (isInsideRenderMethod(node)) {
return;
}

const parentComponent = getClosestParentComponentNode(node);
if (parentComponent) {
const parentName = resolveParentComponentName(parentComponent);

// Exclude lowercase parents, e.g. function createTestComponent()
// Expect all components used in JSX are UpperCamelCase
if (parentName && parentName[0] === parentName[0].toLowerCase()) {
return;
}

let message = parentName
? generateErrorMessageWithParentName(parentName)
: ERROR_MESSAGE_WITHOUT_NAME;

if (isDeclaredInsideProps) {
message += COMPONENT_AS_PROPS_INFO;
}

context.report({node, message});
}
}

// --------------------------------------------------------------------------
// Public
// --------------------------------------------------------------------------

return {
FunctionDeclaration(node) { validate(node); },
ArrowFunctionExpression(node) { validate(node); },
FunctionExpression(node) { validate(node); },
ClassDeclaration(node) { validate(node); }
};
})
};

0 comments on commit ca684ef

Please sign in to comment.