Skip to content

Commit 1643c00

Browse files
feat(no-unstable-default-props): add safeDefaultProps option, closes #1312 (#1313)
Co-authored-by: REL1CX <[email protected]>
1 parent 69e89ca commit 1643c00

File tree

3 files changed

+264
-5
lines changed

3 files changed

+264
-5
lines changed

packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-default-props.mdx

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ react-x/no-unstable-default-props
2020
`strict-typescript`
2121
`strict-type-checked`
2222

23+
**Features**
24+
25+
`⚙️`
26+
2327
## Description
2428

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

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

37+
## Rule Options
38+
39+
This rule has a single options object with the following property:
40+
41+
- `safeDefaultProps` (default: `[]`): An array of identifier names or regex patterns that are safe to use as default props.
42+
3343
## Examples
3444

3545
### Failing
@@ -167,6 +177,134 @@ function MyComponent({ num = 3, str = "foo", bool = true }: MyComponentProps) {
167177
}
168178
```
169179

180+
## Examples with `safeDefaultProps`
181+
182+
This option allows you to allowlist specific constructor or factory method identifiers that create value-type objects safe to use as default props.
183+
184+
### Configuration
185+
186+
```tsx
187+
{
188+
"@eslint-react/no-unstable-default-props": ["error", {
189+
"safeDefaultProps": ["Vector3", "Color3", "vector", "/^Immutable.*/"]
190+
}]
191+
}
192+
```
193+
194+
### Failing
195+
196+
```tsx
197+
import React from "react";
198+
199+
interface MyComponentProps {
200+
position: Vector3;
201+
}
202+
203+
// Without configuration, this would fail
204+
function MyComponent({ position = new Vector3(0, 0, 0) }: MyComponentProps) {
205+
// ^^^^^^^^^^^^^^^^^^^^^^
206+
// - A/an 'new expression' as default prop.
207+
return null;
208+
}
209+
```
210+
211+
```tsx
212+
import React from "react";
213+
214+
interface MyComponentProps {
215+
cache: Cache;
216+
}
217+
218+
// CustomCache is not in the allowlist, so this still fails
219+
function MyComponent({ cache = new CustomCache() }: MyComponentProps) {
220+
// ^^^^^^^^^^^^^^^^^
221+
// - A/an 'new expression' as default prop.
222+
return null;
223+
}
224+
```
225+
226+
```tsx
227+
import React from "react";
228+
229+
interface MyComponentProps {
230+
items: string[];
231+
}
232+
233+
// Object and array literals always fail regardless of configuration
234+
function MyComponent({ items = [] }: MyComponentProps) {
235+
// ^^
236+
// - A/an 'array expression' as default prop.
237+
return null;
238+
}
239+
```
240+
241+
### Passing
242+
243+
```tsx
244+
import React from "react";
245+
246+
interface MyComponentProps {
247+
position: Vector3;
248+
}
249+
250+
// Vector3 is in the allowlist, so constructor calls are allowed
251+
function MyComponent({ position = new Vector3(0, 0, 0) }: MyComponentProps) {
252+
return null;
253+
}
254+
```
255+
256+
```tsx
257+
import React from "react";
258+
259+
interface MyComponentProps {
260+
color: Color3;
261+
}
262+
263+
// Color3 is in the allowlist, so factory methods are allowed
264+
function MyComponent({ color = Color3.Red() }: MyComponentProps) {
265+
return null;
266+
}
267+
```
268+
269+
```tsx
270+
import React from "react";
271+
272+
interface MyComponentProps {
273+
position: Vector3;
274+
}
275+
276+
// 'vector' is in the allowlist, so member expression calls are allowed
277+
function MyComponent({ position = vector.create(0, 0, 0) }: MyComponentProps) {
278+
return null;
279+
}
280+
```
281+
282+
```tsx
283+
import React from "react";
284+
285+
interface MyComponentProps {
286+
data: ImmutableMap<string, number>;
287+
}
288+
289+
// Matches the regex pattern /^Immutable.*/
290+
function MyComponent({ data = ImmutableMap.of() }: MyComponentProps) {
291+
return null;
292+
}
293+
```
294+
295+
```tsx
296+
import React from "react";
297+
298+
interface MyComponentProps {
299+
list: ImmutableList<string>;
300+
}
301+
302+
// Also matches the regex pattern /^Immutable.*/
303+
function MyComponent({ list = ImmutableList.of("a", "b") }: MyComponentProps) {
304+
return null;
305+
}
306+
```
307+
170308
## Implementation
171309

172310
- [Rule Source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-default-props.ts)

packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-default-props.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@ ruleTester.run(RULE_NAME, rule, {
9494
},
9595
}],
9696
},
97+
{
98+
code: tsx`
99+
function MyComponent({ position = new Vector3(0, 0, 0) }) {
100+
return null
101+
}
102+
`,
103+
errors: [{
104+
messageId: MESSAGE_ID,
105+
data: {
106+
forbiddenType: "new expression",
107+
propName: "position",
108+
},
109+
}],
110+
},
97111
{
98112
code: tsx`
99113
function App({ foo = {}, ...rest }) {
@@ -163,9 +177,68 @@ ruleTester.run(RULE_NAME, rule, {
163177
`,
164178
errors: expectedViolations,
165179
},
180+
{
181+
code: tsx`
182+
function MyComponent({ position = new CustomClass() }) {
183+
return null
184+
}
185+
`,
186+
errors: [{
187+
messageId: MESSAGE_ID,
188+
data: {
189+
forbiddenType: "new expression",
190+
propName: "position",
191+
},
192+
}],
193+
options: [{ safeDefaultProps: ["Vector3"] }],
194+
},
195+
{
196+
code: tsx`
197+
function MyComponent({
198+
obj = {},
199+
items = [],
200+
}) {
201+
return null
202+
}
203+
`,
204+
errors: [{
205+
messageId: MESSAGE_ID,
206+
data: {
207+
forbiddenType: "object expression",
208+
propName: "obj",
209+
},
210+
}, {
211+
messageId: MESSAGE_ID,
212+
data: {
213+
forbiddenType: "array expression",
214+
propName: "items",
215+
},
216+
}],
217+
options: [{ safeDefaultProps: ["Vector3"] }],
218+
},
166219
],
167220
valid: [
168221
...allValid,
222+
{
223+
code: tsx`
224+
function MyComponent({ position = new Vector3(0, 0, 0) }) {
225+
return null
226+
}
227+
`,
228+
options: [{ safeDefaultProps: ["Vector3"] }],
229+
},
230+
{
231+
code: tsx`
232+
function MyComponent({
233+
position = vector.create(0, 0, 0),
234+
data = ImmutableMap.of(),
235+
standard = 5,
236+
}) {
237+
return null
238+
}
239+
`,
240+
options: [{ safeDefaultProps: ["vector", "/^Immutable.*/"] }],
241+
},
169242
tsx`
170243
const emptyFunction = () => {}
171244

packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-default-props.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import * as AST from "@eslint-react/ast";
22
import { isReactHookCall, useComponentCollector } from "@eslint-react/core";
33
import { getOrElseUpdate } from "@eslint-react/eff";
4-
import { type RuleContext, type RuleFeature } from "@eslint-react/shared";
4+
import { type RuleContext, type RuleFeature, toRegExp } from "@eslint-react/shared";
55
import { getObjectType } from "@eslint-react/var";
6+
import type { TSESTree } from "@typescript-eslint/types";
67
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
8+
import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
79
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
810
import type { CamelCase } from "string-ts";
911
import { match } from "ts-pattern";
@@ -16,7 +18,32 @@ export const RULE_FEATURES = [] as const satisfies RuleFeature[];
1618

1719
export type MessageID = CamelCase<typeof RULE_NAME>;
1820

19-
export default createRule<[], MessageID>({
21+
type Options = readonly [
22+
{
23+
safeDefaultProps?: readonly string[];
24+
},
25+
];
26+
27+
const defaultOptions = [
28+
{
29+
safeDefaultProps: [],
30+
},
31+
] as const satisfies Options;
32+
33+
const schema = [
34+
{
35+
type: "object",
36+
additionalProperties: false,
37+
properties: {
38+
safeDefaultProps: {
39+
type: "array",
40+
items: { type: "string" },
41+
},
42+
},
43+
},
44+
] satisfies [JSONSchema4];
45+
46+
export default createRule<Options, MessageID>({
2047
meta: {
2148
type: "problem",
2249
docs: {
@@ -27,16 +54,31 @@ export default createRule<[], MessageID>({
2754
noUnstableDefaultProps:
2855
"A/an '{{forbiddenType}}' as default prop. This could lead to potential infinite render loop in React. Use a variable instead of '{{forbiddenType}}'.",
2956
},
30-
schema: [],
57+
schema,
3158
},
3259
name: RULE_NAME,
3360
create,
34-
defaultOptions: [],
61+
defaultOptions,
3562
});
3663

37-
export function create(context: RuleContext<MessageID, []>): RuleListener {
64+
function extractIdentifier(node: TSESTree.Node): string | null {
65+
if (node.type === T.NewExpression && node.callee.type === T.Identifier) {
66+
return node.callee.name;
67+
}
68+
if (node.type === T.CallExpression && node.callee.type === T.MemberExpression) {
69+
const { object } = node.callee;
70+
if (object.type === T.Identifier) {
71+
return object.name;
72+
}
73+
}
74+
return null;
75+
}
76+
77+
export function create(context: RuleContext<MessageID, Options>, [options]: Options): RuleListener {
3878
const { ctx, listeners } = useComponentCollector(context);
3979
const declarators = new WeakMap<AST.TSESTreeFunction, AST.ObjectDestructuringVariableDeclarator[]>();
80+
const { safeDefaultProps = [] } = options;
81+
const safePatterns = safeDefaultProps.map((s) => toRegExp(s));
4082

4183
return {
4284
...listeners,
@@ -82,6 +124,12 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
82124
if (isReactHookCall(construction.node)) {
83125
continue;
84126
}
127+
if (safePatterns.length > 0) {
128+
const identifier = extractIdentifier(right);
129+
if (identifier != null && safePatterns.some((pattern) => pattern.test(identifier))) {
130+
continue;
131+
}
132+
}
85133
const forbiddenType = AST.toDelimiterFormat(right);
86134
context.report({
87135
messageId: "noUnstableDefaultProps",

0 commit comments

Comments
 (0)