Skip to content

Commit de47574

Browse files
committed
feat: add rule 'prefer-readonly-props', closes #590
1 parent 068989b commit de47574

File tree

8 files changed

+192
-3
lines changed

8 files changed

+192
-3
lines changed

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@
6161
"@tsconfig/node20": "20.1.4",
6262
"@tsconfig/strictest": "2.0.5",
6363
"@types/node": "20.14.9",
64+
"@types/react": "18.3.3",
65+
"@types/react-dom": "18.3.0",
6466
"@typescript-eslint/eslint-plugin": "8.0.0-alpha.40",
6567
"@typescript-eslint/parser": "8.0.0-alpha.40",
6668
"@typescript-eslint/rule-tester": "8.0.0-alpha.40",
@@ -90,6 +92,8 @@
9092
"markdownlint": "0.34.0",
9193
"pathe": "1.1.2",
9294
"publint": "0.2.8",
95+
"react": "^18.3.1",
96+
"react-dom": "^18.3.1",
9397
"skott": "0.35.2",
9498
"sort-package-json": "2.10.0",
9599
"tiny-invariant": "1.3.3",
@@ -132,6 +136,7 @@
132136
"object.hasown": "npm:@nolyfill/object.hasown@^1",
133137
"object.values": "npm:@nolyfill/object.values@^1",
134138
"string.prototype.matchall": "npm:@nolyfill/string.prototype.matchall@^1",
139+
"ts-api-utils": "^1.3.0",
135140
"typedarray": "npm:@nolyfill/typedarray@^1",
136141
"typedoc": "0.26.3",
137142
"typedoc-plugin-markdown": "4.1.1",

packages/plugins/eslint-plugin-react-x/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
"@typescript-eslint/scope-manager": "8.0.0-alpha.40",
5252
"@typescript-eslint/type-utils": "8.0.0-alpha.40",
5353
"@typescript-eslint/types": "8.0.0-alpha.40",
54-
"@typescript-eslint/utils": "8.0.0-alpha.40"
54+
"@typescript-eslint/utils": "8.0.0-alpha.40",
55+
"is-immutable-type": "4.0.0"
5556
},
5657
"devDependencies": {
5758
"dedent": "1.5.3",

packages/plugins/eslint-plugin-react-x/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import noUnusedClassComponentMembers from "./rules/no-unused-class-component-mem
3939
import noUnusedState from "./rules/no-unused-state";
4040
import noUselessFragment from "./rules/no-useless-fragment";
4141
import preferDestructuringAssignment from "./rules/prefer-destructuring-assignment";
42+
import preferReadonlyProps from "./rules/prefer-readonly-props";
4243
import preferShorthandBoolean from "./rules/prefer-shorthand-boolean";
4344
import preferShorthandFragment from "./rules/prefer-shorthand-fragment";
4445

@@ -88,6 +89,7 @@ export const rules = {
8889
"no-unused-state": noUnusedState,
8990
"no-useless-fragment": noUselessFragment,
9091
"prefer-destructuring-assignment": preferDestructuringAssignment,
92+
"prefer-readonly-props": preferReadonlyProps,
9193
"prefer-shorthand-boolean": preferShorthandBoolean,
9294
"prefer-shorthand-fragment": preferShorthandFragment,
9395
} as const;

packages/plugins/eslint-plugin-react-x/src/rules/prefer-destructuring-assignment.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ ruleTester.run(RULE_NAME, rule, {
6767
6868
// Code to expect error
6969
export const App = memo(
70-
function App(props) {
70+
function App(props: Props) {
7171
return <div ref={ref}>{props.day}</div>;
7272
}
7373
);
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import dedent from "dedent";
2+
3+
import { allValid, ruleTesterWithTypes } from "../../../../../test";
4+
import rule, { RULE_NAME } from "./prefer-readonly-props";
5+
6+
ruleTesterWithTypes.run(RULE_NAME, rule, {
7+
invalid: [
8+
{
9+
code: dedent`
10+
const App = (props: { id: string; className: string }) => {
11+
return <div id={props.id} className={props.className} />
12+
}
13+
`,
14+
errors: [
15+
{
16+
messageId: "PREFER_READONLY_PROPS",
17+
},
18+
],
19+
},
20+
{
21+
code: dedent`
22+
function App(props: { id: string; className: string }) {
23+
return <div id={props.id} className={props.className} />
24+
}
25+
`,
26+
errors: [
27+
{
28+
messageId: "PREFER_READONLY_PROPS",
29+
},
30+
],
31+
},
32+
{
33+
code: dedent`
34+
const App = function (props: { id: string; className: string }) {
35+
return <div id={props.id} className={props.className} />
36+
}
37+
`,
38+
errors: [
39+
{
40+
messageId: "PREFER_READONLY_PROPS",
41+
},
42+
],
43+
},
44+
{
45+
code: dedent`
46+
import { FC } from "react";
47+
const App: FC<{ id: string; className: string }> = (props) => {
48+
return <div id={props.id} className={props.className} />
49+
}
50+
`,
51+
errors: [
52+
{
53+
messageId: "PREFER_READONLY_PROPS",
54+
},
55+
],
56+
},
57+
{
58+
code: dedent`
59+
import React from "react";
60+
61+
export const App: React.FC<{ id: string; className: string }> = (props) => {
62+
return <div className={props.className} id={props.id} />
63+
}
64+
`,
65+
errors: [
66+
{
67+
messageId: "PREFER_READONLY_PROPS",
68+
},
69+
],
70+
},
71+
{
72+
code: dedent`
73+
import React from "react";
74+
75+
export const App: React.FC<{ id: string; className: string } | { readonly id: string; readonly className: string }> = (props) => {
76+
return <div className={props.className} id={props.id} />
77+
}
78+
`,
79+
errors: [
80+
{
81+
messageId: "PREFER_READONLY_PROPS",
82+
},
83+
],
84+
},
85+
],
86+
valid: [
87+
...allValid,
88+
dedent`
89+
import React from "react";
90+
91+
type DeepReadonly<T> = Readonly<{[K in keyof T]: T[K] extends (number | string | symbol) ? Readonly<T[K]> : T[K] extends Array<infer A> ? Readonly<Array<DeepReadonly<A>>> : DeepReadonly<T[K]>;}>;
92+
93+
export const App: React.FC<DeepReadonly<{ id: string; className: string }>> = (props) => {
94+
return <div className={props.className} id={props.id} />
95+
}
96+
`,
97+
dedent`
98+
import React from "react";
99+
import { ReadonlyDeep } from "type-fest";
100+
101+
export const App: React.FC<ReadonlyDeep<{ id: string; className: string }>> = (props) => {
102+
return <div className={props.className} id={props.id} />
103+
}
104+
`,
105+
],
106+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useComponentCollector } from "@eslint-react/core";
2+
import { getConstrainedTypeAtLocation } from "@typescript-eslint/type-utils";
3+
import { ESLintUtils } from "@typescript-eslint/utils";
4+
import { getTypeImmutability, isImmutable, isReadonlyDeep, isReadonlyShallow, isUnknown } from "is-immutable-type";
5+
import type { ConstantCase } from "string-ts";
6+
import * as tsutils from "ts-api-utils";
7+
import type ts from "typescript";
8+
9+
import { createRule } from "../utils";
10+
11+
export const RULE_NAME = "prefer-readonly-props";
12+
13+
export type MessageID = ConstantCase<typeof RULE_NAME>;
14+
15+
export default createRule<[], MessageID>({
16+
meta: {
17+
type: "problem",
18+
docs: {
19+
description: "",
20+
},
21+
messages: {
22+
PREFER_READONLY_PROPS: "Prefer readonly props",
23+
},
24+
schema: [],
25+
},
26+
name: RULE_NAME,
27+
create(context) {
28+
const services = ESLintUtils.getParserServices(context);
29+
const { ctx, listeners } = useComponentCollector(context);
30+
return {
31+
...listeners,
32+
"Program:exit"(node) {
33+
function isReadonlyType(type: ts.Type) {
34+
try {
35+
// TODO: getImmutability may throw when checking complex generic types
36+
const im = getTypeImmutability(services.program, type);
37+
return isUnknown(im) || isImmutable(im) || isReadonlyShallow(im) || isReadonlyDeep(im);
38+
} catch {
39+
return true;
40+
}
41+
}
42+
const components = ctx.getAllComponents(node);
43+
for (const [_, component] of components) {
44+
const props = component.node.params.at(0);
45+
if (!props) continue;
46+
const propsType = getConstrainedTypeAtLocation(services, props);
47+
const propsTypes = tsutils.unionTypeParts(propsType);
48+
if (propsTypes.some((type) => !isReadonlyType(type))) {
49+
context.report({
50+
messageId: "PREFER_READONLY_PROPS",
51+
node: props,
52+
});
53+
}
54+
}
55+
},
56+
};
57+
},
58+
defaultOptions: [],
59+
}) satisfies ESLintUtils.RuleModule<MessageID>;

pnpm-lock.yaml

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
File renamed without changes.

0 commit comments

Comments
 (0)