>
+ ) {
+ const [count, setCount] = useState(0);
+
+ return (
+ <>
+
+
+
+ >
+ );
+ }
+ `,
+ tsx`
+ ///
+ ///
+
import * as React from "react";
type DeepReadonly = Readonly<{[K in keyof T]: T[K] extends (number | string | symbol) ? Readonly : T[K] extends Array ? Readonly>> : DeepReadonly;}>;
diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts b/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts
index f40ca2703b..08b137b1c7 100644
--- a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts
+++ b/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts
@@ -1,9 +1,11 @@
import { useComponentCollector } from "@eslint-react/core";
import type { RuleContext, RuleFeature } from "@eslint-react/shared";
import { getConstrainedTypeAtLocation, isTypeReadonly } from "@typescript-eslint/type-utils";
-import { ESLintUtils } from "@typescript-eslint/utils";
+import { ESLintUtils, type ParserServicesWithTypeInformation } from "@typescript-eslint/utils";
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
+import { getTypeImmutability, isImmutable, isReadonlyDeep, isReadonlyShallow, isUnknown } from "is-immutable-type";
import type { CamelCase } from "string-ts";
+import type ts from "typescript";
import { createRule } from "../utils";
@@ -45,7 +47,7 @@ export function create(context: RuleContext): RuleListener {
continue;
}
const propsType = getConstrainedTypeAtLocation(services, props);
- if (isTypeReadonly(services.program, propsType)) {
+ if (isTypeReadonlyLoose(services, propsType)) {
continue;
}
context.report({ messageId: "preferReadOnlyProps", node: props });
@@ -53,3 +55,13 @@ export function create(context: RuleContext): RuleListener {
},
};
}
+
+function isTypeReadonlyLoose(services: ParserServicesWithTypeInformation, type: ts.Type): boolean {
+ if (isTypeReadonly(services.program, type)) return true;
+ try {
+ const im = getTypeImmutability(services.program, type);
+ return isUnknown(im) || isImmutable(im) || isReadonlyShallow(im) || isReadonlyDeep(im);
+ } catch {
+ return true;
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 001cc74723..2861f22475 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1115,6 +1115,9 @@ importers:
eslint:
specifier: ^8.57.0 || ^9.0.0
version: 9.23.0(jiti@2.4.2)
+ is-immutable-type:
+ specifier: ^5.0.1
+ version: 5.0.1(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2)
string-ts:
specifier: ^2.2.1
version: 2.2.1
@@ -1435,7 +1438,6 @@ packages:
'@babel/plugin-proposal-private-methods@7.18.6':
resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==}
engines: {node: '>=6.9.0'}
- deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.
peerDependencies:
'@babel/core': ^7.0.0-0
@@ -5107,6 +5109,12 @@ packages:
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+ is-immutable-type@5.0.1:
+ resolution: {integrity: sha512-LkHEOGVZZXxGl8vDs+10k3DvP++SEoYEAJLRk6buTFi6kD7QekThV7xHS0j6gpnUCQ0zpud/gMDGiV4dQneLTg==}
+ peerDependencies:
+ eslint: '*'
+ typescript: ^5.8.2
+
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
@@ -5401,7 +5409,6 @@ packages:
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
- deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -6619,6 +6626,11 @@ packages:
resolution: {integrity: sha512-LcM3W5HEyzTaXUeQITV8ploUOGe+zuuoFYsCfPscFLhx3bZn2sSfHMKxsULVG/zA7an9UhReiHv4Kk/6QzlpXQ==}
engines: {node: '>=18.0.0'}
+ ts-declaration-location@1.0.6:
+ resolution: {integrity: sha512-QwtM5UZ8S/NpDvSx4u2EHJgLx2+we7qN8sgyOia4nTpJlke3NO1s1Eb2ea/8IFlkc60b71SILGqWBzGGDnNeSw==}
+ peerDependencies:
+ typescript: ^5.8.2
+
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -11107,6 +11119,16 @@ snapshots:
is-hexadecimal@2.0.1: {}
+ is-immutable-type@5.0.1(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2):
+ dependencies:
+ '@typescript-eslint/type-utils': 8.27.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2)
+ eslint: 9.23.0(jiti@2.4.2)
+ ts-api-utils: 2.1.0(typescript@5.8.2)
+ ts-declaration-location: 1.0.6(typescript@5.8.2)
+ typescript: 5.8.2
+ transitivePeerDependencies:
+ - supports-color
+
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
@@ -13007,6 +13029,11 @@ snapshots:
dependencies:
typescript: 5.8.2
+ ts-declaration-location@1.0.6(typescript@5.8.2):
+ dependencies:
+ minimatch: 9.0.5
+ typescript: 5.8.2
+
ts-interface-checker@0.1.13: {}
ts-morph@25.0.1: