Skip to content
Merged
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 @@ -20,6 +20,10 @@ react-x/no-unstable-default-props
`strict-typescript`
`strict-type-checked`

**Features**

`⚙️`

## Description

Prevents using referential-type values as default props in object destructuring.
Expand All @@ -30,6 +34,12 @@ This harms performance as it means that React will have to re-evaluate hooks and

To fix the violations, the easiest way is to use a referencing variable in module scope instead of using the literal values.

## Rule Options

This rule has a single options object with the following property:

- `safeDefaultProps` (default: `[]`): An array of identifier names or regex patterns that are safe to use as default props.

## Examples

### Failing
Expand Down Expand Up @@ -167,6 +177,134 @@ function MyComponent({ num = 3, str = "foo", bool = true }: MyComponentProps) {
}
```

## Examples with `safeDefaultProps`

This option allows you to allowlist specific constructor or factory method identifiers that create value-type objects safe to use as default props.

### Configuration

```tsx
{
"@eslint-react/no-unstable-default-props": ["error", {
"safeDefaultProps": ["Vector3", "Color3", "vector", "/^Immutable.*/"]
}]
}
```

### Failing

```tsx
import React from "react";

interface MyComponentProps {
position: Vector3;
}

// Without configuration, this would fail
function MyComponent({ position = new Vector3(0, 0, 0) }: MyComponentProps) {
// ^^^^^^^^^^^^^^^^^^^^^^
// - A/an 'new expression' as default prop.
return null;
}
```

```tsx
import React from "react";

interface MyComponentProps {
cache: Cache;
}

// CustomCache is not in the allowlist, so this still fails
function MyComponent({ cache = new CustomCache() }: MyComponentProps) {
// ^^^^^^^^^^^^^^^^^
// - A/an 'new expression' as default prop.
return null;
}
```

```tsx
import React from "react";

interface MyComponentProps {
items: string[];
}

// Object and array literals always fail regardless of configuration
function MyComponent({ items = [] }: MyComponentProps) {
// ^^
// - A/an 'array expression' as default prop.
return null;
}
```

### Passing

```tsx
import React from "react";

interface MyComponentProps {
position: Vector3;
}

// Vector3 is in the allowlist, so constructor calls are allowed
function MyComponent({ position = new Vector3(0, 0, 0) }: MyComponentProps) {
return null;
}
```

```tsx
import React from "react";

interface MyComponentProps {
color: Color3;
}

// Color3 is in the allowlist, so factory methods are allowed
function MyComponent({ color = Color3.Red() }: MyComponentProps) {
return null;
}
```

```tsx
import React from "react";

interface MyComponentProps {
position: Vector3;
}

// 'vector' is in the allowlist, so member expression calls are allowed
function MyComponent({ position = vector.create(0, 0, 0) }: MyComponentProps) {
return null;
}
```

```tsx
import React from "react";

interface MyComponentProps {
data: ImmutableMap<string, number>;
}

// Matches the regex pattern /^Immutable.*/
function MyComponent({ data = ImmutableMap.of() }: MyComponentProps) {
return null;
}
```

```tsx
import React from "react";

interface MyComponentProps {
list: ImmutableList<string>;
}

// Also matches the regex pattern /^Immutable.*/
function MyComponent({ list = ImmutableList.of("a", "b") }: MyComponentProps) {
return null;
}
```

## Implementation

- [Rule Source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-default-props.ts)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ ruleTester.run(RULE_NAME, rule, {
},
}],
},
{
code: tsx`
function MyComponent({ position = new Vector3(0, 0, 0) }) {
return null
}
`,
errors: [{
messageId: MESSAGE_ID,
data: {
forbiddenType: "new expression",
propName: "position",
},
}],
},
{
code: tsx`
function App({ foo = {}, ...rest }) {
Expand Down Expand Up @@ -163,9 +177,68 @@ ruleTester.run(RULE_NAME, rule, {
`,
errors: expectedViolations,
},
{
code: tsx`
function MyComponent({ position = new CustomClass() }) {
return null
}
`,
errors: [{
messageId: MESSAGE_ID,
data: {
forbiddenType: "new expression",
propName: "position",
},
}],
options: [{ safeDefaultProps: ["Vector3"] }],
},
{
code: tsx`
function MyComponent({
obj = {},
items = [],
}) {
return null
}
`,
errors: [{
messageId: MESSAGE_ID,
data: {
forbiddenType: "object expression",
propName: "obj",
},
}, {
messageId: MESSAGE_ID,
data: {
forbiddenType: "array expression",
propName: "items",
},
}],
options: [{ safeDefaultProps: ["Vector3"] }],
},
],
valid: [
...allValid,
{
code: tsx`
function MyComponent({ position = new Vector3(0, 0, 0) }) {
return null
}
`,
options: [{ safeDefaultProps: ["Vector3"] }],
},
{
code: tsx`
function MyComponent({
position = vector.create(0, 0, 0),
data = ImmutableMap.of(),
standard = 5,
}) {
return null
}
`,
options: [{ safeDefaultProps: ["vector", "/^Immutable.*/"] }],
},
tsx`
const emptyFunction = () => {}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as AST from "@eslint-react/ast";
import { isReactHookCall, useComponentCollector } from "@eslint-react/core";
import { getOrElseUpdate } from "@eslint-react/eff";
import { type RuleContext, type RuleFeature } from "@eslint-react/shared";
import { type RuleContext, type RuleFeature, toRegExp } from "@eslint-react/shared";
import { getObjectType } from "@eslint-react/var";
import type { TSESTree } from "@typescript-eslint/types";
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
import type { CamelCase } from "string-ts";
import { match } from "ts-pattern";
Expand All @@ -16,7 +18,32 @@ export const RULE_FEATURES = [] as const satisfies RuleFeature[];

export type MessageID = CamelCase<typeof RULE_NAME>;

export default createRule<[], MessageID>({
type Options = readonly [
{
safeDefaultProps?: readonly string[];
},
];

const defaultOptions = [
{
safeDefaultProps: [],
},
] as const satisfies Options;

const schema = [
{
type: "object",
additionalProperties: false,
properties: {
safeDefaultProps: {
type: "array",
items: { type: "string" },
},
},
},
] satisfies [JSONSchema4];

export default createRule<Options, MessageID>({
meta: {
type: "problem",
docs: {
Expand All @@ -27,16 +54,31 @@ export default createRule<[], MessageID>({
noUnstableDefaultProps:
"A/an '{{forbiddenType}}' as default prop. This could lead to potential infinite render loop in React. Use a variable instead of '{{forbiddenType}}'.",
},
schema: [],
schema,
},
name: RULE_NAME,
create,
defaultOptions: [],
defaultOptions,
});

export function create(context: RuleContext<MessageID, []>): RuleListener {
function extractIdentifier(node: TSESTree.Node): string | null {
if (node.type === T.NewExpression && node.callee.type === T.Identifier) {
return node.callee.name;
}
if (node.type === T.CallExpression && node.callee.type === T.MemberExpression) {
const { object } = node.callee;
if (object.type === T.Identifier) {
return object.name;
}
}
return null;
}

export function create(context: RuleContext<MessageID, Options>, [options]: Options): RuleListener {
const { ctx, listeners } = useComponentCollector(context);
const declarators = new WeakMap<AST.TSESTreeFunction, AST.ObjectDestructuringVariableDeclarator[]>();
const { safeDefaultProps = [] } = options;
const safePatterns = safeDefaultProps.map((s) => toRegExp(s));

return {
...listeners,
Expand Down Expand Up @@ -82,6 +124,12 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
if (isReactHookCall(construction.node)) {
continue;
}
if (safePatterns.length > 0) {
const identifier = extractIdentifier(right);
if (identifier != null && safePatterns.some((pattern) => pattern.test(identifier))) {
continue;
}
}
const forbiddenType = AST.toDelimiterFormat(right);
context.report({
messageId: "noUnstableDefaultProps",
Expand Down