Skip to content

Commit 969eccb

Browse files
feat(no-unstable-default-props): add safeDefaultProps option to allowlist value-type constructors
1 parent 520e8e9 commit 969eccb

File tree

2 files changed

+124
-5
lines changed

2 files changed

+124
-5
lines changed

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: 51 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,10 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
82124
if (isReactHookCall(construction.node)) {
83125
continue;
84126
}
127+
const identifier = extractIdentifier(right);
128+
if (identifier != null && safePatterns.some((pattern) => pattern.test(identifier))) {
129+
continue;
130+
}
85131
const forbiddenType = AST.toDelimiterFormat(right);
86132
context.report({
87133
messageId: "noUnstableDefaultProps",

0 commit comments

Comments
 (0)