Skip to content

Commit 873816a

Browse files
authored
fix: prefer-read-only-props false positive using React types, closes #962 (#1008)
1 parent 36f0c82 commit 873816a

File tree

4 files changed

+67
-4
lines changed

4 files changed

+67
-4
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"@typescript-eslint/types": "^8.27.0",
6060
"@typescript-eslint/utils": "^8.27.0",
6161
"compare-versions": "^6.1.1",
62+
"is-immutable-type": "^5.0.1",
6263
"string-ts": "^2.2.1",
6364
"ts-pattern": "^5.6.2"
6465
},

packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,29 @@ ruleTesterWithTypes.run(RULE_NAME, rule, {
428428
/// <reference types="react" />
429429
/// <reference types="react-dom" />
430430
431+
import { useState } from 'react';
432+
import './App.css';
433+
434+
export default function App(
435+
props: Readonly<React.HTMLAttributes<HTMLDivElement>>
436+
) {
437+
const [count, setCount] = useState(0);
438+
439+
return (
440+
<>
441+
<div className="card" id={props.id}>
442+
<button type="button" onClick={() => setCount((count) => count + 1)}>
443+
count is {count}
444+
</button>
445+
</div>
446+
</>
447+
);
448+
}
449+
`,
450+
tsx`
451+
/// <reference types="react" />
452+
/// <reference types="react-dom" />
453+
431454
import * as React from "react";
432455
433456
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]>;}>;

packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { useComponentCollector } from "@eslint-react/core";
22
import type { RuleContext, RuleFeature } from "@eslint-react/shared";
33
import { getConstrainedTypeAtLocation, isTypeReadonly } from "@typescript-eslint/type-utils";
4-
import { ESLintUtils } from "@typescript-eslint/utils";
4+
import { ESLintUtils, type ParserServicesWithTypeInformation } from "@typescript-eslint/utils";
55
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
6+
import { getTypeImmutability, isImmutable, isReadonlyDeep, isReadonlyShallow, isUnknown } from "is-immutable-type";
67
import type { CamelCase } from "string-ts";
8+
import type ts from "typescript";
79

810
import { createRule } from "../utils";
911

@@ -45,11 +47,21 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
4547
continue;
4648
}
4749
const propsType = getConstrainedTypeAtLocation(services, props);
48-
if (isTypeReadonly(services.program, propsType)) {
50+
if (isTypeReadonlyLoose(services, propsType)) {
4951
continue;
5052
}
5153
context.report({ messageId: "preferReadOnlyProps", node: props });
5254
}
5355
},
5456
};
5557
}
58+
59+
function isTypeReadonlyLoose(services: ParserServicesWithTypeInformation, type: ts.Type): boolean {
60+
if (isTypeReadonly(services.program, type)) return true;
61+
try {
62+
const im = getTypeImmutability(services.program, type);
63+
return isUnknown(im) || isImmutable(im) || isReadonlyShallow(im) || isReadonlyDeep(im);
64+
} catch {
65+
return true;
66+
}
67+
}

pnpm-lock.yaml

Lines changed: 29 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)